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:
@@ -53,20 +53,17 @@
|
||||
{% if ansible_available and g.has_target %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;">
|
||||
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||
data-msg="Remove all STOPPED containers on {{ g.host_name|e }}? This cannot be undone."
|
||||
onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
|
||||
data-confirm="Remove all STOPPED containers on {{ g.host_name }}? This cannot be undone.">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="containers">
|
||||
<button type="submit" class="btn btn-sm">Prune stopped containers</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||
data-msg="Remove ALL unused images on {{ g.host_name|e }} (docker image prune -a)? Any image not used by a container is deleted."
|
||||
onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
|
||||
data-confirm="Remove ALL unused images on {{ g.host_name }} (docker image prune -a)? Any image not used by a container is deleted.">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="images">
|
||||
<button type="submit" class="btn btn-sm">Prune unused images</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||
data-msg="Run a full system prune on {{ g.host_name|e }}? Removes stopped containers, unused networks, dangling images and build cache."
|
||||
onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
|
||||
data-confirm="Run a full system prune on {{ g.host_name }}? Removes stopped containers, unused networks, dangling images and build cache.">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="system">
|
||||
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
|
||||
</form>
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/rotate-token" style="margin:0;"
|
||||
onsubmit="return confirm('Rotate token? The agent stops reporting until reinstalled/updated with the new token.');">{{ csrf_field() }}
|
||||
data-confirm="Rotate token? The agent stops reporting until reinstalled/updated with the new token.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Rotate token</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0;"
|
||||
onsubmit="return confirm('Remove this agent registration? Metrics stop until re-registered.');">{{ csrf_field() }}
|
||||
data-confirm="Remove this agent registration? Metrics stop until re-registered.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
{% if reg and session.user_role == 'admin' %}
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0.75rem 0 0;"
|
||||
onsubmit="return confirm('Clear this pending registration? Its token is discarded.');">{{ csrf_field() }}
|
||||
data-confirm="Clear this pending registration? Its token is discarded.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Clear pending registration</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -156,12 +156,12 @@
|
||||
<td class="td-actions">
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/rotate-token"
|
||||
style="display:inline;"
|
||||
onsubmit="return confirm('Rotate the token for this host? The existing agent will stop working until reinstalled with the new token.');">{{ csrf_field() }}
|
||||
data-confirm="Rotate the token for this host? The existing agent will stop working until reinstalled with the new token.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Rotate token</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete"
|
||||
style="display:inline;"
|
||||
onsubmit="return confirm('Delete this host registration? The agent will be unable to report metrics until re-registered.');">{{ csrf_field() }}
|
||||
data-confirm="Delete this host registration? The agent will be unable to report metrics until re-registered.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{% endif %}
|
||||
<span class="hist-range" style="margin-left:auto;display:inline-flex;border:1px solid var(--border-mid);border-radius:5px;overflow:hidden;">
|
||||
{% for opt in [1, 6, 24] %}
|
||||
<button type="button" onclick="histRange({{ wid }}, {{ opt }})"
|
||||
<button type="button" onclick="histRange({{ wid|int }}, {{ opt|int }})"
|
||||
style="border:0;cursor:pointer;padding:0.1rem 0.45rem;font-size:0.72rem;
|
||||
background:{% if opt == hours %}var(--accent){% else %}transparent{% endif %};
|
||||
color:{% if opt == hours %}#fff{% else %}var(--text-muted){% endif %};">{{ opt }}h</button>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
<button type="button"
|
||||
class="range-btn{% if current_range == key %} active{% endif %}"
|
||||
data-range="{{ key }}"
|
||||
onclick="setTimeRange('{{ key }}')">{{ key }}</button>
|
||||
onclick="setTimeRange(this.dataset.range)">{{ key }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<td class="td-actions">
|
||||
{% if row.state == 'active' %}
|
||||
<form method="post" action="/ai-access/tokens/{{ t.id }}/revoke" style="margin:0;"
|
||||
onsubmit='return confirm({{ ("Revoke “" ~ t.name ~ "”? Any client using it stops working immediately.")|tojson }})'>{{ csrf_field() }}
|
||||
data-confirm="Revoke “{{ t.name }}”? Any client using it stops working immediately.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
</form>
|
||||
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button>
|
||||
data-confirm="Delete rule {{ rule.name }}?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<td class="td-actions">
|
||||
<form method="post" action="/alerts/maintenance/{{ w.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete window "{{ w.name }}"?')">Delete</button>
|
||||
data-confirm="Delete window "{{ w.name }}"?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
{% if view_editable and session.user_role == 'admin' %}
|
||||
<a href="/ansible/playbooks/edit/{{ view_source }}/{{ view_path }}" class="btn btn-sm">Edit</a>
|
||||
<form method="post" action="/ansible/playbooks/delete" style="display:inline;"
|
||||
onsubmit="return confirm('Delete {{ view_path }}?');">{{ csrf_field() }}
|
||||
data-confirm="Delete {{ view_path }}?">{{ csrf_field() }}
|
||||
<input type="hidden" name="source_name" value="{{ view_source }}">
|
||||
<input type="hidden" name="playbook_path" value="{{ view_path }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<a href="/ansible/inventory/groups/{{ grp.id }}" class="btn btn-sm btn-ghost">Edit</a>
|
||||
<form method="post" action="/ansible/inventory/groups/{{ grp.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete group {{ grp.name }}?')">×</button>
|
||||
data-confirm="Delete group {{ grp.name }}?">×</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<a href="/ansible/inventory/targets/{{ tgt.id }}" class="btn btn-sm btn-ghost">Edit</a>
|
||||
<form method="post" action="/ansible/inventory/targets/{{ tgt.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete target {{ tgt.name }}?')">×</button>
|
||||
data-confirm="Delete target {{ tgt.name }}?">×</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
{% if editing %}
|
||||
<form method="post" action="/ansible/playbooks/delete" style="margin-top:1rem;"
|
||||
onsubmit="return confirm('Delete {{ playbook_path }}?');">{{ csrf_field() }}
|
||||
data-confirm="Delete {{ playbook_path }}?">{{ csrf_field() }}
|
||||
<input type="hidden" name="source_name" value="{{ source_name }}">
|
||||
<input type="hidden" name="playbook_path" value="{{ playbook_path }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete playbook</button>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
|
||||
<div style="margin-left:auto;display:flex;gap:0.5rem;">
|
||||
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
|
||||
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" onsubmit="return confirm('Cancel this run?');">{{ csrf_field() }}
|
||||
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" data-confirm="Cancel this run?">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</form>
|
||||
<a href="?edit={{ s.id }}#form" class="btn btn-sm btn-ghost">Edit</a>
|
||||
<form method="post" action="/ansible/schedules/{{ s.id }}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('Delete schedule {{ s.name }}?');">{{ csrf_field() }}
|
||||
data-confirm="Delete schedule {{ s.name }}?">{{ csrf_field() }}
|
||||
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
document.addEventListener('htmx:configRequest', function (ev) {
|
||||
ev.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
|
||||
});
|
||||
// A form (or its submit button) with data-confirm="…" asks before it
|
||||
// submits. The prompt is attribute text read through dataset, never
|
||||
// parsed as JavaScript, so a name inside it cannot run as script (#5099).
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var ask = (ev.submitter && ev.submitter.dataset.confirm) || ev.target.dataset.confirm;
|
||||
if (ask && !window.confirm(ask)) { ev.preventDefault(); ev.stopPropagation(); }
|
||||
}, true);
|
||||
</script>
|
||||
<title>{% block title %}Steward{% endblock %}</title>
|
||||
{# Every asset is served by Steward itself: it runs with no outbound network (rule 164). #}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
{% if my_dashboards | length > 1 %}
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/delete" style="margin:0;"
|
||||
onsubmit="return confirm('Delete \'{{ dash.name }}\'?')">{{ csrf_field() }}
|
||||
data-confirm="Delete '{{ dash.name }}'?">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/d/{{ dashboard.id }}/share/revoke/{{ token.id }}"
|
||||
style="margin:0;" onsubmit="return confirm('Revoke this share link?')">{{ csrf_field() }}
|
||||
style="margin:0;" data-confirm="Revoke this share link?">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
|
||||
{% if session.user_role == 'admin' %}
|
||||
<form method="post" action="/hosts/{{ host.id }}/delete" style="margin:0;"
|
||||
onsubmit="return confirm('Delete host {{ host.name }}? Monitors and history are removed.');">{{ csrf_field() }}
|
||||
data-confirm="Delete host {{ host.name }}? Monitors and history are removed.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -78,7 +78,7 @@
|
||||
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
|
||||
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm btn-ghost" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
|
||||
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('Delete monitor {{ m.name }}?');">{{ csrf_field() }}
|
||||
data-confirm="Delete monitor {{ m.name }}?">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<a href="/hosts/{{ host.id }}/edit" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
|
||||
<form method="post" action="/hosts/{{ host.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete {{ host.name }}?')">Delete</button>
|
||||
data-confirm="Delete {{ host.name }}?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
{{ "Pause" if m.enabled else "Resume" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('Delete monitor {{ m.name }}?');">{{ csrf_field() }}
|
||||
data-confirm="Delete monitor {{ m.name }}?">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
{% endif %}
|
||||
|
||||
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
|
||||
onclick="toggleAnsibleEdit({{ idx }})">Edit</button>
|
||||
onclick="toggleAnsibleEdit({{ idx|int }})">Edit</button>
|
||||
|
||||
<button class="btn btn-danger btn-sm" style="font-size:0.78rem;"
|
||||
hx-post="/settings/ansible/sources/{{ idx }}/remove"
|
||||
@@ -72,7 +72,7 @@
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Type</label>
|
||||
<select name="type" id="ansible-edit-type-{{ idx }}"
|
||||
onchange="updateAnsibleEditFields({{ idx }})">
|
||||
onchange="updateAnsibleEditFields({{ idx|int }})">
|
||||
<option value="local" {% if src.type == 'local' %}selected{% endif %}>local</option>
|
||||
<option value="git" {% if src.type == 'git' %}selected{% endif %}>git</option>
|
||||
</select>
|
||||
@@ -118,7 +118,7 @@
|
||||
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
|
||||
<button type="submit" class="btn btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm"
|
||||
onclick="toggleAnsibleEdit({{ idx }})">Cancel</button>
|
||||
onclick="toggleAnsibleEdit({{ idx|int }})">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:0.4rem;flex-shrink:0;">
|
||||
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
|
||||
onclick="toggleRepoEdit({{ idx }})">Edit</button>
|
||||
onclick="toggleRepoEdit({{ idx|int }})">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" style="font-size:0.78rem;"
|
||||
hx-post="/settings/plugins/repos/{{ idx }}/remove"
|
||||
hx-target="#plugin-repos"
|
||||
@@ -47,7 +47,7 @@
|
||||
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
|
||||
<button type="submit" class="btn btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm"
|
||||
onclick="toggleRepoEdit({{ idx }})">Cancel</button>
|
||||
onclick="toggleRepoEdit({{ idx|int }})">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<span style="color:var(--text-muted);font-size:0.78rem;">Click to select — add to a host's <code>authorized_keys</code> to authorize Steward manually.</span>
|
||||
</div>
|
||||
<form method="post" action="/settings/ansible/generate-key"
|
||||
onsubmit="return confirm('Regenerate the managed key? Already-provisioned hosts will reject Steward until you re-provision them.');">{{ csrf_field() }}
|
||||
data-confirm="Regenerate the managed key? Already-provisioned hosts will reject Steward until you re-provision them.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Regenerate key</button>
|
||||
</form>
|
||||
{% else %}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="section-title">Monitoring capabilities</div>
|
||||
<form method="post" action="/settings/plugins/restart/"
|
||||
hx-post="/settings/plugins/restart/" hx-target="#restart-banner" hx-swap="outerHTML"
|
||||
onsubmit="return confirm('Restart the app now? This will briefly interrupt all monitoring.')"
|
||||
data-confirm="Restart the app now? This will briefly interrupt all monitoring."
|
||||
style="margin:0;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Restart App</button>
|
||||
</form>
|
||||
@@ -134,7 +134,7 @@
|
||||
</div>
|
||||
{% if orphan.removable %}
|
||||
<form method="post" action="/settings/plugins/orphans/{{ orphan.name }}/remove/"
|
||||
onsubmit="return confirm('Remove stored settings for "{{ orphan.name }}"? Its saved configuration, including any credentials, will be discarded. This cannot be undone.');"
|
||||
data-confirm="Remove stored settings for "{{ orphan.name }}"? Its saved configuration, including any credentials, will be discarded. This cannot be undone."
|
||||
style="margin:0;flex-shrink:0;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm" style="font-size:0.78rem;">Remove</button>
|
||||
</form>
|
||||
|
||||
@@ -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