diff --git a/scanner/dashboard/console.html b/scanner/dashboard/console.html index 7ec262af..9a6df66e 100644 --- a/scanner/dashboard/console.html +++ b/scanner/dashboard/console.html @@ -103,7 +103,7 @@

AppGuardrail Console

if(!r.ok)throw new Error("Request failed ("+r.status+")."); return r.json(); } -function pill(n,color){return n>0?`${n}`:`0`;} +function pill(n,color){return n>0?`${esc(n)}`:`0`;} function scrollDetailIntoView(element){ if(window.matchMedia("(prefers-reduced-motion: reduce)").matches){ element.scrollIntoView(); @@ -125,15 +125,15 @@

AppGuardrail Console

["New since last scan",latest.new_blocking||0], ["Critical",c.CRITICAL||0], ["Scans stored",scans.length], - ].map(([l,n])=>`
${l}
${n}
`).join(""); + ].map(([l,n])=>`
${esc(l)}
${esc(n)}
`).join(""); const ord=[...scans].reverse(); const max=Math.max(1,...ord.map(s=>s.deploy_blocking||0)); $("#trend").innerHTML=ord.map(s=>{const h=Math.round(6+((s.deploy_blocking||0)/max)*54); const col=(s.deploy_blocking||0)>0?"var(--crit)":"var(--ok)"; return ``;}).join("")||'No scans yet.'; - $("#history tbody").innerHTML=scans.map(s=>` + $("#history tbody").innerHTML=scans.map(s=>` ${esc(s.created_at)}${esc(s.repo||"—")}${esc((s.commit||"—").slice(0,10))} - ${s.total}${pill(s.deploy_blocking,"var(--crit)")}${pill(s.new_blocking,"var(--high)")}`).join("")||'No scans. POST to /api/v1/scans from CI.'; + ${esc(s.total)}${pill(s.deploy_blocking,"var(--crit)")}${pill(s.new_blocking,"var(--high)")}`).join("")||'No scans. POST to /api/v1/scans from CI.'; document.querySelectorAll("tr.scan").forEach(tr=>{ tr.onclick=()=>detail(tr.dataset.id,tr); tr.addEventListener('keydown', e => { if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); detail(tr.dataset.id,tr); } }); diff --git a/tests/test_console_dashboard_xss_behavior.py b/tests/test_console_dashboard_xss_behavior.py new file mode 100644 index 00000000..45dec8e7 --- /dev/null +++ b/tests/test_console_dashboard_xss_behavior.py @@ -0,0 +1,112 @@ +"""Executable DOM-sink regression for the standalone AppGuardrail console.""" + +import re +import subprocess + +from pathlib import Path + + +CONSOLE_PATH = ( + Path(__file__).resolve().parents[1] / "scanner" / "dashboard" / "console.html" +) + + +def _load_script_prefix() -> str: + """Return the shipped console script through `load` without detail boot code.""" + html = CONSOLE_PATH.read_text(encoding="utf-8") + match = re.search(r"", html, re.IGNORECASE) + assert match is not None + prefix, separator, _ = match.group("script").partition( + "async function detail(id,tr){" + ) + assert separator + return prefix + + +def test_untrusted_scan_summary_values_are_escaped_before_innerhtml() -> None: + """Run the real `load` renderer and keep hostile scan metadata inert at HTML sinks.""" + script = _load_script_prefix() + harness = r""" +const elements = new Map(); +function element(name) { + return { + name, + innerHTML: '', + textContent: '', + classList: { + add() {}, + remove() {}, + contains() { return false; } + } + }; +} +for (const selector of ['#msg','#app','#logout','#conn','#stats','#trend','#history tbody','#detail']) { + elements.set(selector, element(selector)); +} +const document = { + querySelector(selector) { return elements.get(selector) || element(selector); }, + querySelectorAll() { return []; }, + addEventListener() {}, + activeElement: null +}; +const sessionStorage = { getItem() { return null; } }; +const window = { matchMedia() { return {matches: true}; } }; +const payload = { + id: '7" autofocus onfocus="globalThis.pwned=1', + created_at: '', + repo: '', + commit: 'deadbeef', + total: '', + deploy_blocking: 2, + new_blocking: 1, + severity_counts: {CRITICAL: ''} +}; +async function fetch() { + return { + status: 200, + ok: true, + async json() { return {scans: [payload]}; } + }; +} +""" + assertions = r""" +(async () => { + await load(); + const stats = elements.get('#stats').innerHTML; + const trend = elements.get('#trend').innerHTML; + const history = elements.get('#history tbody').innerHTML; + const combined = stats + trend + history; + + for (const raw of [ + payload.created_at, + payload.repo, + payload.total, + payload.severity_counts.CRITICAL, + `data-id="${payload.id}"` + ]) { + if (combined.includes(raw)) throw new Error(`raw hostile value reached innerHTML: ${raw}`); + } + if (!stats.includes('<svg onload="globalThis.pwned=5"></svg>')) { + throw new Error('critical summary payload was not HTML-escaped'); + } + if (!trend.includes('<svg onload="globalThis.pwned=2"></svg>')) { + throw new Error('trend timestamp payload was not HTML-escaped'); + } + if (!history.includes('<img src=x onerror="globalThis.pwned=4">')) { + throw new Error('scan total payload was not HTML-escaped'); + } + if (!history.includes('data-id="7" autofocus onfocus="globalThis.pwned=1"')) { + throw new Error('scan id did not remain inside the quoted data-id attribute'); + } + if (globalThis.pwned !== undefined) throw new Error('hostile payload executed'); +})().catch(error => { console.error(error); process.exit(1); }); +""" + + completed = subprocess.run( + ["node", "-e", harness + script + assertions], + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr