[security] Stored XSS in the dashboard — unescaped API/request data rendered via innerHTML #23

Closed
opened 2026-08-10 15:45:30 +00:00 by james.oates · 1 comment
Owner

Severity: high. Live stored XSS, one vector reachable by an unauthenticated remote attacker.

Problem

dashboard.html builds five views by interpolating server data straight into innerHTML with no escaping:

  • Logs (worst): ${l.request_path} and ${l.request_method} — captured from actual proxied traffic. An attacker sends a request through the proxy to any logging-enabled service with a path like /<img src=x onerror=…>; it's stored in request_logs; when an admin opens the dashboard, it executes in the admin's authenticated session. No credentials required — just the ability to hit a logged service.
  • Services / L4 routes / static sites: ${s.name}, ${s.target_host}, ${s.routing_hosts}, ${r.match_sni}, etc. — registerable by any API-key holder (including scoped service keys). Authenticated stored XSS → admin session.

Impact

XSS runs with the admin's session (post-#22 the cookie is httpOnly, so it can't be exfiltrated — but the script can ride the session): deregister services, rewrite TLS domains, register malicious routes, read logs. Effectively full admin takeover triggered by viewing the dashboard.

Fix

  1. Escape all interpolated values before innerHTML (an esc() HTML-escaper applied to every data ${…}, or build nodes via textContent/DOM APIs instead of string templates). This is the direct fix and is small.
  2. Content-Security-Policy as defense-in-depth (blocks inline/injected script even if an escape is missed). Larger: the dashboard currently uses inline <script> and onclick= handlers, so a strict script-src (no unsafe-inline) needs those moved to a nonce'd/external script and inline handlers removed.

Priority

Pre-existing (not introduced by the session-auth work), but it's the top residual security risk — above passkeys (step 2). The session-cookie change limited XSS exfiltration; it did not remove the XSS itself. Recommend fixing the escaping next, CSP soon after. Surfaced 2026-08-10.

**Severity: high.** Live stored XSS, one vector reachable by an **unauthenticated** remote attacker. ## Problem `dashboard.html` builds five views by interpolating server data straight into `innerHTML` with no escaping: - **Logs (worst):** `${l.request_path}` and `${l.request_method}` — captured from **actual proxied traffic**. An attacker sends a request through the proxy to any logging-enabled service with a path like `/<img src=x onerror=…>`; it's stored in `request_logs`; when an admin opens the dashboard, it executes **in the admin's authenticated session**. No credentials required — just the ability to hit a logged service. - **Services / L4 routes / static sites:** `${s.name}`, `${s.target_host}`, `${s.routing_hosts}`, `${r.match_sni}`, etc. — registerable by any API-key holder (including scoped service keys). Authenticated stored XSS → admin session. ## Impact XSS runs with the admin's session (post-#22 the cookie is httpOnly, so it can't be exfiltrated — but the script can *ride* the session): deregister services, rewrite TLS domains, register malicious routes, read logs. Effectively full admin takeover triggered by viewing the dashboard. ## Fix 1. **Escape all interpolated values** before `innerHTML` (an `esc()` HTML-escaper applied to every data `${…}`, or build nodes via `textContent`/DOM APIs instead of string templates). This is the direct fix and is small. 2. **Content-Security-Policy** as defense-in-depth (blocks inline/injected script even if an escape is missed). Larger: the dashboard currently uses inline `<script>` and `onclick=` handlers, so a strict `script-src` (no `unsafe-inline`) needs those moved to a nonce'd/external script and inline handlers removed. ## Priority Pre-existing (not introduced by the session-auth work), but it's the top residual security risk — **above passkeys (step 2)**. The session-cookie change limited XSS *exfiltration*; it did not remove the XSS itself. Recommend fixing the escaping next, CSP soon after. Surfaced 2026-08-10.
Author
Owner

Fixed in 086de00.

Two-layer remediation:

  1. Output escaping (the fix). Introduced an auto-escaping html tagged-template helper — every interpolated value is HTML-escaped by default, so injected markup renders as inert text. All five render functions (services, L4 routes, static sites, request logs) now go through it. The one deliberately-trusted fragment ("logging on") uses an explicit raw() opt-out. Escaping is now the default, so future templates are safe without per-sink discipline.

  2. Content-Security-Policy (defense-in-depth). The dashboard is now served with a strict per-request policy: default-src 'none'; script-src 'nonce-<random>'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'. Script runs only if it carries that response's fresh random nonce, so any escape that slips through in future can't execute. Required nonce-ing the inline <script> and converting the two inline onclick= handlers to addEventListener — the page now has zero inline script. Also sets X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer.

Tests added: nonce injection + policy assertions (no script-src 'unsafe-inline'/'unsafe-eval') and per-response nonce uniqueness. Full suite green (182), clippy + fmt clean.

Fixed in `086de00`. **Two-layer remediation:** 1. **Output escaping (the fix).** Introduced an auto-escaping `html` tagged-template helper — every interpolated value is HTML-escaped by default, so injected markup renders as inert text. All five render functions (services, L4 routes, static sites, request logs) now go through it. The one deliberately-trusted fragment ("logging on") uses an explicit `raw()` opt-out. Escaping is now the *default*, so future templates are safe without per-sink discipline. 2. **Content-Security-Policy (defense-in-depth).** The dashboard is now served with a strict per-request policy: `default-src 'none'; script-src 'nonce-<random>'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`. Script runs only if it carries that response's fresh random nonce, so any escape that slips through in future can't execute. Required nonce-ing the inline `<script>` and converting the two inline `onclick=` handlers to `addEventListener` — the page now has zero inline script. Also sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. Tests added: nonce injection + policy assertions (no `script-src 'unsafe-inline'`/`'unsafe-eval'`) and per-response nonce uniqueness. Full suite green (182), clippy + fmt clean.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
IsoHex/edge-router#23
No description provided.