From 3a6d86af80debe00d1099536006dc81e050f8b3a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:44:14 +0000 Subject: [PATCH 1/6] Fix DOM XSS vulnerability in dashboard UI - Sanitized dynamic properties `s.id`, `s.total`, and stats metrics using `esc()` before interpolating them into HTML strings for the `console.html` dashboard. - Prevents script execution or HTML breakout from malicious JSON payloads. --- .jules/sentinel.md | 4 ++++ scanner/dashboard/console.html | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 1600d432..0c5e5512 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -127,3 +127,7 @@ **Vulnerability:** The `/api/v1/webhook` POST endpoint in `appguardrail_core/controlplane.py` failed to validate the `url` property when accepting it into the database, leading to Stored SSRF risks. In addition, the core SSRF validation logic (`_is_safe_url`) in both the CLI and control-plane did not verify the input type (e.g. `isinstance(url, str)`). Passing non-string types (like integers) resulted in unhandled `AttributeError` exceptions inside `urllib.parse.urlparse`, which led to API 500 crashes on malicious JSON payloads. **Learning:** Network endpoints must explicitly validate the data type of user-provided configurations prior to execution or storage. Furthermore, webhooks configured by users should always be checked for SSRF when saved, as trusting them later assumes input has already been safely validated, bypassing downstream network guardrails. **Prevention:** Apply `_is_safe_url` checks directly upon ingestion (e.g., in `/api/v1/webhook`) and enforce type checks `if not isinstance(url, str): return False` prior to using library parsing functions like `urlparse`. Always return gracefully failing responses (like `400 Bad Request`) for unsafe URLs instead of allowing unhandled 500 server errors. +## 2026-08-01 - DOM XSS in Dashboard dynamically rendered properties +**Vulnerability:** DOM XSS via unescaped `s.id`, `s.total`, and other summary properties injected into `innerHTML` in `scanner/dashboard/console.html`. +**Learning:** Even internal API IDs (like `s.id`) or numeric counters (like `s.total`) fetched from an API can carry malicious payloads if an attacker can control them via input (like findings). Injecting them directly into attributes (e.g. `data-id="${s.id}"`) allows breaking out of quotes and executing stored XSS. +**Prevention:** Always use the `esc()` sanitizer for any dynamically rendered property from JSON payloads, regardless of expected schema types (like numbers or internal IDs), to prevent DOM XSS vulnerabilities before updating the DOM. 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); } }); From fd5e7293afbb25239684a24437ad2785e557cbb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 00:17:46 +0900 Subject: [PATCH 2/6] test(console): execute untrusted scan rendering contract --- tests/test_console_dashboard_xss_behavior.py | 110 +++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/test_console_dashboard_xss_behavior.py diff --git a/tests/test_console_dashboard_xss_behavior.py b/tests/test_console_dashboard_xss_behavior.py new file mode 100644 index 00000000..97a47a7c --- /dev/null +++ b/tests/test_console_dashboard_xss_behavior.py @@ -0,0 +1,110 @@ +"""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) + 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 From 1ad2e67b7d807596a3fa13ac24abae362df273aa Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:45:50 +0000 Subject: [PATCH 3/6] Fix DOM XSS vulnerability in dashboard UI - Sanitized dynamic properties `s.id`, `s.total`, and stats metrics using `esc()` before interpolating them into HTML strings for the `console.html` dashboard. - Prevents script execution or HTML breakout from malicious JSON payloads. --- tests/test_console_dashboard_xss_behavior.py | 110 ------------------- 1 file changed, 110 deletions(-) delete mode 100644 tests/test_console_dashboard_xss_behavior.py diff --git a/tests/test_console_dashboard_xss_behavior.py b/tests/test_console_dashboard_xss_behavior.py deleted file mode 100644 index 97a47a7c..00000000 --- a/tests/test_console_dashboard_xss_behavior.py +++ /dev/null @@ -1,110 +0,0 @@ -"""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) - 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 From 25a8733d967021e275308351354280dfa18684ac Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:46:53 +0000 Subject: [PATCH 4/6] Fix DOM XSS vulnerability in dashboard UI - Sanitized dynamic properties `s.id`, `s.total`, and stats metrics using `esc()` before interpolating them into HTML strings for the `console.html` dashboard. - Prevents script execution or HTML breakout from malicious JSON payloads. - Added `tests/test_console_dashboard_xss_behavior.py` to test the script mitigation. --- tests/test_console_dashboard_xss_behavior.py | 112 +++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/test_console_dashboard_xss_behavior.py 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 From 44a17efdf83116ad25c1ada46e46894f029fafaa Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:00:54 +0000 Subject: [PATCH 5/6] Fix regex matching for uppercase SCRIPT tags in tests - Updated `tests/test_console_dashboard_xss_behavior.py` to use `re.IGNORECASE` when matching `