diff --git a/docs/superpowers/specs/2026-07-08-xss-sink-csp-design.md b/docs/superpowers/specs/2026-07-08-xss-sink-csp-design.md new file mode 100644 index 0000000..8fe3031 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-xss-sink-csp-design.md @@ -0,0 +1,168 @@ +# XSS Sink Remediation + CSP + +**Date:** 2026-07-08 +**Status:** Approved +**Finding:** Latent XSS sink in `utils.showAlert` (`templates/index.html`) — unsanitized +value written to `innerHTML`, no CSP served. Proven exploitable to token exfiltration +via direct `localStorage` seeding, but no network-accessible write path to the seeded +value exists today, so not externally exploitable. Treat as defense-in-depth. + +## Goal + +Eliminate the `innerHTML` sink structurally and add a nonce-based Content-Security-Policy +so that even if a future code path writes attacker-controlled data into the alert +message, script execution and exfiltration are blocked. + +## Non-goals + +- Changes to the OIDC flow, token validation, or `AUTHORIZED_USERS`. +- Refactoring the single-file frontend layout. +- Adding a JS test framework. + +## Approach + +- **Sink:** DOM-built alerts (no `innerHTML`), chosen over escape-and-allowlist because + it removes the sink structurally rather than guarding it. +- **CSP:** Per-request nonce, chosen over hash (survives script edits) and over + `'unsafe-inline'` (which would not block the proven `onerror` exfil path). + +## Design + +### 1. Sink fix — `templates/index.html` + +Replace `showAlert` (currently at line 162) with a version that builds the alert via +`document.createElement` and renders all dynamic content through `textContent` / +`createTextNode`. Accept either a plain string or a structured `{title, body}`: + +```js +showAlert(element, message, type = 'info') { + element.replaceChildren(); + const alert = document.createElement('div'); + alert.className = `alert alert-${type}`; + alert.setAttribute('role', 'alert'); + if (typeof message === 'string') { + alert.textContent = message; + } else if (message && typeof message === 'object') { + if (message.title) { + const strong = document.createElement('strong'); + strong.textContent = message.title; + alert.appendChild(strong); + } + if (message.body) { + if (message.title) { + alert.appendChild(document.createTextNode(' ')); + } + alert.appendChild(document.createTextNode(message.body)); + } + } + element.appendChild(alert); +} +``` + +`type` is interpolated into `className` (a DOM property, not parsed as HTML), so it is +safe; it is also only ever a literal (`'success'`, `'danger'`, `'warning'`, `'info'`) +at the call sites. + +#### Caller updates + +Three callers use `` for bold formatting and must switch to the structured form: + +| Line | Current | New | +|------|---------|-----| +| 468 | `` `Secret Phrase: ${cachedSecret}` `` | `{ title: 'Secret Phrase:', body: cachedSecret }` | +| 584 | `` `Secret Phrase: ${data.secret_phrase}` `` | `{ title: 'Secret Phrase:', body: data.secret_phrase }` | +| 621 | `` `1 Bitcoin (BTC) = CHF ${price.toLocaleString(...)}` `` | `` { title: `1 Bitcoin (BTC) = CHF ${price.toLocaleString(...)}` } `` | + +The remaining 11 callers pass plain strings and require no change — the `typeof message +=== 'string'` branch renders them as `textContent`. + +No caller performs HTML escaping; `textContent` makes it unnecessary. The sink is gone: +there is no `innerHTML` assignment anywhere in `showAlert`. + +### 2. CSP — `main.py` + +Modify `read_root()` (`main.py:326-331`) to generate a per-request nonce, inject it into +the inline `