# 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 `