fix(ui): delete prompts use data-confirm, so a quote in a name no longer runs script (#5099)
#5099 "A name containing a quote runs script when an admin clicks Delete". Inline handlers like onclick="return confirm('Delete {{ rule.name }}?')" are HTML-decoded before the JavaScript is parsed, so autoescaping did not stop a name such as x');fetch(…);// from running. - Every prompt (22 handlers, plus the docker prune and token revoke ones) is now data-confirm="…", read through dataset by one submit listener in base.html; nothing rendered is parsed as JavaScript. - The remaining inline handlers take integers only (|int), and the time range buttons pass this.dataset.range. - tests/test_inline_handlers.py fails on any {{ }} in an inline handler that is not |int, with a self-test; tests/_templates.py is the one template walker the template guards now share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
"""Where Steward's templates live, for the guards that read every one."""
|
||||
import pathlib
|
||||
|
||||
import steward
|
||||
|
||||
REPO = pathlib.Path(steward.__file__).parent.parent
|
||||
|
||||
|
||||
def template_files() -> list[pathlib.Path]:
|
||||
"""Every first-party .html template: core and each plugin's."""
|
||||
roots = [REPO / "steward" / "templates", *(REPO / "plugins").glob("*/templates")]
|
||||
return sorted(p for root in roots for p in root.rglob("*.html"))
|
||||
@@ -1,16 +1,14 @@
|
||||
"""The shared format filters and status_class (#5093): one way to write a size,
|
||||
a rate, a span and a moment, and one grading against Settings → Thresholds."""
|
||||
import pathlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jinja2
|
||||
import pytest
|
||||
|
||||
import steward
|
||||
from steward.core import formatting as f
|
||||
from steward.core.settings import threshold_style_for, to_thresholds_cfg
|
||||
from tests._templates import REPO as _REPO, template_files as _templates
|
||||
|
||||
_REPO = pathlib.Path(steward.__file__).parent.parent
|
||||
TH = to_thresholds_cfg({})
|
||||
NOW = datetime(2026, 10, 6, 18, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
@@ -88,11 +86,6 @@ def test_threshold_style_is_graded_by_status_class():
|
||||
|
||||
# ── every template compiles against the app's filters ────────────────────────
|
||||
|
||||
def _templates():
|
||||
roots = [_REPO / "steward" / "templates", *(_REPO / "plugins").glob("*/templates")]
|
||||
return sorted(p for root in roots for p in root.rglob("*.html"))
|
||||
|
||||
|
||||
async def test_compiling_catches_an_unknown_filter(app):
|
||||
with pytest.raises(jinja2.TemplateAssertionError, match="no_such"):
|
||||
app.jinja_env.compile("{{ 1|no_such }}")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""No template puts a rendered value into inline JavaScript (#5099).
|
||||
|
||||
An onclick/onsubmit attribute is HTML-decoded before its JavaScript is
|
||||
parsed, so autoescaping does not protect a name placed inside it: a monitor
|
||||
called x');fetch(…);// ran as script when an admin clicked Delete. A
|
||||
prompt goes in data-confirm="…" (base.html asks with it); any other value a
|
||||
handler needs is an integer, rendered with |int.
|
||||
"""
|
||||
import re
|
||||
|
||||
from tests._templates import REPO as _REPO, template_files as _templates
|
||||
|
||||
_HANDLER = re.compile(r"""\bon[a-z]+\s*=\s*("[^"]*"|'[^']*')""", re.I)
|
||||
_VALUE = re.compile(r"\{\{(.*?)\}\}", re.S)
|
||||
|
||||
|
||||
def unsafe_handler_values(text: str) -> list[str]:
|
||||
"""Each {{ … }} inside an inline handler that is not an |int."""
|
||||
return [m.group(0) for h in _HANDLER.finditer(text) for m in _VALUE.finditer(h.group(1))
|
||||
if not re.search(r"\|\s*int\s*$", m.group(1).strip())]
|
||||
|
||||
|
||||
def test_the_guard_can_fail():
|
||||
page = """
|
||||
<button onclick="return confirm('Delete rule {{ rule.name }}?')">x</button>
|
||||
<form onsubmit='return confirm({{ msg|tojson }})'>
|
||||
<button onclick="toggle({{ idx|int }})">ok</button>
|
||||
<button data-confirm="Delete {{ rule.name }}?" data-range="{{ key }}">ok</button>
|
||||
"""
|
||||
assert unsafe_handler_values(page) == ["{{ rule.name }}", "{{ msg|tojson }}"]
|
||||
|
||||
|
||||
def test_no_template_renders_a_value_into_an_inline_handler():
|
||||
files = _templates()
|
||||
assert len(files) > 100
|
||||
found = {str(p.relative_to(_REPO)): hits for p in files if (hits := unsafe_handler_values(p.read_text()))}
|
||||
assert not found, f"use data-confirm, or render an integer with |int: {found}"
|
||||
|
||||
|
||||
def test_base_asks_before_a_data_confirm_form_submits():
|
||||
base = (_REPO / "steward" / "templates" / "base.html").read_text()
|
||||
assert "dataset.confirm" in base and "window.confirm(ask)" in base
|
||||
assert any("data-confirm=" in p.read_text() for p in _templates())
|
||||
@@ -11,6 +11,7 @@ import pathlib
|
||||
import re
|
||||
|
||||
import steward
|
||||
from tests._templates import template_files as _templates
|
||||
|
||||
_REPO = pathlib.Path(steward.__file__).parent.parent
|
||||
_STATIC = _REPO / "steward" / "static"
|
||||
@@ -24,11 +25,6 @@ _HEX = re.compile(r"""(?:[:(,]\s*|(?<![\w-]=)['"])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]
|
||||
HEX_CEILING = 75 # measured 2026-10-06 (#5092); lower it as pages move onto tokens, never raise it
|
||||
|
||||
|
||||
def _templates():
|
||||
for root in [_REPO / "steward" / "templates", *(_REPO / "plugins").glob("*/templates")]:
|
||||
yield from root.rglob("*.html")
|
||||
|
||||
|
||||
def _sheets():
|
||||
return [p for p in _STATIC.rglob("*.css") if "vendor" not in p.parts]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user