Files
hermes-webui/tests/test_inline_handler_arg_escaping.py
T
Sebastian B d4e102fd38 fix(ui): encode inline on* handler args with shared jsArg() (#3797 class)
esc() is HTML-only escaping; the browser decodes attribute entities BEFORE
parsing an inline handler, so onclick="fn('${esc(id)}')" let any id
containing a quote break out of the JS string literal. _kanbanJsArg solved
this for kanban dependencies only; promote it to jsArg() in ui.js and route
every inline-handler argument through it (panels.js cron/kanban/notes/
passkeys/gateway/checkpoints, sessions.js handoff hint, onboarding.js OAuth
device code). Adds a Playwright round-trip proof and a source guard against
reintroducing the esc()-only pattern.
2026-08-28 00:45:45 +01:00

103 lines
4.3 KiB
Python

"""Inline on* handler arguments must go through jsArg(), never esc() alone.
esc() is HTML-only escaping. The browser decodes entities in the attribute
value BEFORE the inline handler's JavaScript is parsed, so an argument
interpolated as onclick="fn('${esc(value)}')" lets a quote in the value
break out of the JS string literal (the #3797 bug class). Kanban dependency
buttons fixed this locally as _kanbanJsArg (#3797); that helper is promoted
to a shared jsArg() in static/ui.js. The Playwright test proves the
breakout and the round-trip fix; the source guard keeps the pattern from
coming back.
"""
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
# on*="...'...${esc(x)}...'..." — esc()-only encoding used as a quoted JS
# string argument inside an inline handler (wrong escaping context).
_ESC_ONLY_INLINE_ARG = re.compile(r"""on[a-z]+\s*=\s*\"[^\"]*'\s*\$\{esc\(""")
_FIRST_PARTY_JS = sorted(
p.name for p in (ROOT / "static").glob("*.js") if not p.name.endswith(".min.js")
)
def test_no_esc_only_inline_handler_args_remain():
offenders = {}
for name in _FIRST_PARTY_JS:
src = (ROOT / "static" / name).read_text(encoding="utf-8")
lines = [
i + 1
for i, line in enumerate(src.splitlines())
if _ESC_ONLY_INLINE_ARG.search(line)
]
if lines:
offenders[name] = lines
assert not offenders, f"esc()-only inline on* args found (use jsArg()): {offenders}"
def test_jsarg_inline_handler_round_trip():
pw = pytest.importorskip("playwright.sync_api")
from tests.js_source_extract import extract_function
ui_src = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
esc_line = re.search(r"^const esc=.*$", ui_src, re.M)
assert esc_line, "esc() definition not found in static/ui.js"
harness = "\n".join(
[
esc_line.group(0),
extract_function(ui_src, "jsArg"),
"window.__calls = [];",
"window.__alerts = [];",
"window.alert = (m) => window.__alerts.push(String(m));",
"window.loadKanbanTask = (id) => window.__calls.push(id);",
]
)
with pw.sync_playwright() as playwright:
try:
browser = playwright.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
except Exception as exc: # pragma: no cover - no browser binary
pytest.skip(f"chromium unavailable: {exc}")
try:
page = browser.new_page()
page.set_content("<!doctype html><html><body></body></html>")
page.add_script_tag(content=harness)
result = page.evaluate(
r"""() => {
const payload = "');alert('pwned');//";
// Pre-fix pattern: esc()-only argument in a quoted inline handler.
document.body.innerHTML =
'<button id="escBtn" onclick="loadKanbanTask(\'' + esc(payload) + '\')">x</button>';
document.getElementById('escBtn').click();
const escFormAlerts = window.__alerts.slice();
const escFormCalls = window.__calls.slice();
window.__alerts.length = 0;
// Fixed pattern: jsArg() argument, no manual quotes.
document.body.innerHTML =
'<button id="jsBtn" onclick="loadKanbanTask(' + jsArg(payload) + ')">x</button>';
document.getElementById('jsBtn').click();
return {
escFormAlerts,
escFormCalls,
jsFormAlerts: window.__alerts.slice(),
jsFormCall: window.__calls[window.__calls.length - 1],
payload,
};
}"""
)
finally:
browser.close()
# esc()-only demonstrably breaks out of the handler string: injected code
# runs and the received argument is corrupted.
assert result["escFormAlerts"] == ["pwned"]
assert result["escFormCalls"] == [""]
# jsArg() fires no injected code and round-trips the payload exactly.
assert result["jsFormAlerts"] == []
assert result["jsFormCall"] == result["payload"]