Files
demo-oidc/docs/superpowers/specs/2026-07-08-xss-sink-csp-design.md
T

7.0 KiB

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}:

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 <strong> for bold formatting and must switch to the structured form:

Line Current New
468 `<strong>Secret Phrase:</strong> ${cachedSecret}` { title: 'Secret Phrase:', body: cachedSecret }
584 `<strong>Secret Phrase:</strong> ${data.secret_phrase}` { title: 'Secret Phrase:', body: data.secret_phrase }
621 `<strong>1 Bitcoin (BTC) = CHF ${price.toLocaleString(...)}</strong>` { 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 <script> tag, and set the CSP header on the response.

Add import secrets to the imports at the top of main.py.

@app.get("/", response_class=HTMLResponse)
async def read_root():
    """Serve the frontend HTML file"""
    nonce = secrets.token_urlsafe(16)
    with open("templates/index.html", "r") as file:
        html_content = file.read()
    html_content = html_content.replace(
        '<script>', f'<script nonce="{nonce}">', 1
    )
    csp = (
        "default-src 'self'; "
        f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
        "style-src 'self' https://cdn.jsdelivr.net; "
        "connect-src 'self' https://login.infomaniak.com https://api.coingecko.com; "
        "img-src 'self'; "
        "object-src 'none'; "
        "base-uri 'self'; "
        "frame-ancestors 'none'"
    )
    return HTMLResponse(
        content=html_content,
        status_code=200,
        headers={"Content-Security-Policy": csp},
    )

Why this is correct

  • The replace('<script>', ..., 1) targets only the bare inline tag at index.html:62. The Bootstrap tag at line 8 is <script src=...>, which does not match the literal <script> and is left untouched. A src-backed script is authorized by the https://cdn.jsdelivr.net entry in script-src, not by the nonce.
  • The nonce is fresh per request (secrets.token_urlsafe), so an attacker who cannot read the response body cannot guess it.
  • connect-src allows the two fetch origins used by the frontend (api.coingecko.com for the BTC price, login.infomaniak.com for any client-side OIDC call) plus 'self' for the app's own endpoints (/validate-token, /refresh-token, /config, /health).
  • The OIDC login redirect is a top-level navigation (window.location), which is not restricted by connect-src or default-src; no form-action / navigate-to entry is needed.
  • object-src 'none', base-uri 'self', frame-ancestors 'none' are standard hardening.

Scope

Only the HTML route gets the CSP header. JSON API endpoints (/health, /validate-token, etc.) do not serve HTML and do not need a CSP header; they keep their existing behavior. This can be widened to a middleware later if desired, but is not required by this finding.

3. Verification

  • pytest (automated): add a test asserting that GET / returns a Content-Security-Policy header containing nonce- and that the response body contains <script nonce=". Run the full suite (./run_tests.sh) and confirm it stays green. Mind the lru_cache cache-clear and CLIENT_ID gotchas from AGENTS.md when adding the test.
  • Frontend (manual): no JS test framework in the repo. Verify by loading the app in a browser that:
    • The secret-phrase success alert still shows Secret Phrase: in bold followed by the phrase.
    • The BTC price alert still shows the price line in bold.
    • Error alerts (e.g., trigger a token refresh failure) still render the message text.
    • The browser console shows no CSP violations during a normal login flow.

Files touched

  • templates/index.htmlshowAlert rewrite + 3 caller updates.
  • main.pyimport secrets, read_root() nonce injection + CSP header.
  • tests/ — one new test for the CSP header + nonce injection on GET /.

Risk / rollback

  • Low risk. The showAlert signature stays backward-compatible for string callers.
  • If the nonce injection breaks a downstream proxy that rewrites the HTML, rolling back is a single-file revert of read_root() plus the showAlert block.
  • If a CSP directive turns out to block a legitimate resource, the directive can be widened without touching the sink fix.