11 KiB
XSS Sink Remediation + CSP Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Eliminate the innerHTML XSS sink in utils.showAlert and add a nonce-based Content-Security-Policy header to the frontend route.
Architecture: Frontend showAlert is rewritten to build alert DOM via document.createElement / textContent (no innerHTML). Backend read_root() generates a per-request nonce, injects it into the inline <script> tag, and sets a strict CSP header on the HTML response.
Tech Stack: FastAPI (Python 3.14), vanilla JS frontend, pytest + fastapi.testclient.TestClient.
Spec: docs/superpowers/specs/2026-07-08-xss-sink-csp-design.md
Global Constraints
- Run all commands from repo root (relative path
templates/index.htmlinmain.py:329). - Python 3.14 (
.python-version, Dockerfilepython:3.14-alpine). - No lint/typecheck/formatter/CI — pytest is the only verification step.
- Test command:
python -m pytest tests/ --cov=. --cov-config=.coveragerc -v(or./run_tests.sh). @lru_cacheOIDC helpers (main.py:68-129) — callfunc.cache_clear()when patchingrequests. Not relevant to this plan but good to know.CLIENT_IDhas no default (main.py:28) — the test env sets it viarun_tests.sh; the CSP test does not depend on it.
Task 1: Add CSP header + nonce injection to read_root()
Files:
- Modify:
main.py:1-10(addimport secrets) - Modify:
main.py:326-331(read_rootfunction) - Test:
tests/test_main.py(add new test)
Interfaces:
-
Consumes: none
-
Produces:
GET /now returns aContent-Security-Policyresponse header containing a fresh per-request nonce, and the HTML body's inline<script>tag carries a matchingnonce="..."attribute. -
Step 1: Write the failing test
Add this test to tests/test_main.py (after test_get_client_config, around line 33):
def test_root_serves_csp_with_nonce():
"""Test that GET / returns a CSP header with a nonce and the inline script carries it."""
response = client.get("/")
assert response.status_code == 200
csp = response.headers.get("content-security-policy", "")
assert "nonce-" in csp
assert "script-src" in csp
assert "cdn.jsdelivr.net" in csp
assert "connect-src" in csp
assert "login.infomaniak.com" in csp
assert "api.coingecko.com" in csp
assert "object-src 'none'" in csp
body = response.text
assert "<script nonce=" in body
import re
match = re.search(r"nonce-([A-Za-z0-9_-]+)", csp)
assert match is not None
nonce = match.group(1)
assert f'nonce="{nonce}"' in body
- Step 2: Run test to verify it fails
Run: python -m pytest tests/test_main.py::test_root_serves_csp_with_nonce -v
Expected: FAIL — assert "nonce-" in csp fails because no CSP header is set yet (empty string).
- Step 3: Add
import secretstomain.py
In main.py, add import secrets after import os (line 9), keeping it in the stdlib group:
import os
import secrets
from functools import lru_cache
(Line 10's from functools import lru_cache stays where it is — just insert import secrets between lines 9 and 10.)
- Step 4: Rewrite
read_root()with nonce injection + CSP header
Replace the body of read_root() at main.py:326-331 with:
@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},
)
The replace('<script>', ..., 1) targets only the bare inline tag at templates/index.html:62. The Bootstrap tag at line 8 is <script src=...> (does not match the literal <script>) and is authorized by the https://cdn.jsdelivr.net entry in script-src.
- Step 5: Run test to verify it passes
Run: python -m pytest tests/test_main.py::test_root_serves_csp_with_nonce -v
Expected: PASS
- Step 6: Run full suite to confirm no regressions
Run: ./run_tests.sh
Expected: All tests pass (including the new one). Coverage report builds in htmlcov/.
- Step 7: Commit
git add main.py tests/test_main.py
git commit -m "fix(security): add nonce-based CSP header to frontend route"
Task 2: Remove the innerHTML XSS sink in showAlert
Files:
- Modify:
templates/index.html:162-164(showAlertmethod) - Modify:
templates/index.html:468(caller — cached secret) - Modify:
templates/index.html:584(caller — server secret phrase) - Modify:
templates/index.html:619-623(caller — BTC price)
Interfaces:
- Consumes: none
- Produces:
utils.showAlert(element, message, type)now accepts either a plainstring(rendered astextContent) or a structured{title, body}object (title rendered in<strong>astextContent, body rendered as text). TheinnerHTMLsink is gone.
Note on testing: This repo has no JavaScript test framework. This task is verified by (a) the Python test from Task 1 confirming the page still serves correctly, and (b) manual browser verification steps at the end. The CSP from Task 1 provides defense-in-depth: even if a future caller passes attacker-controlled data, inline script execution is blocked.
- Step 1: Rewrite
showAlertto build DOM instead of usinginnerHTML
In templates/index.html, replace the showAlert method (lines 162-164):
Current:
showAlert(element, message, type = 'info') {
element.innerHTML = `<div class="alert alert-${type}" role="alert">${message}</div>`;
},
New:
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);
},
- Step 2: Update caller at line 468 (cached secret phrase)
Current (templates/index.html:468):
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${cachedSecret}`, 'success');
New:
utils.showAlert(elements.secretResult, { title: 'Secret Phrase:', body: cachedSecret }, 'success');
- Step 3: Update caller at line 584 (server secret phrase)
Current (templates/index.html:584):
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${data.secret_phrase}`, 'success');
New:
utils.showAlert(elements.secretResult, { title: 'Secret Phrase:', body: data.secret_phrase }, 'success');
- Step 4: Update caller at lines 619-623 (BTC price)
Current (templates/index.html:619-623):
utils.showAlert(
elements.btcPriceResult,
`<strong>1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}</strong>`,
'success'
);
New:
utils.showAlert(
elements.btcPriceResult,
{ title: `1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}` },
'success'
);
- Step 5: Verify no
innerHTMLremains inshowAlertor its callers
Run: grep -n 'innerHTML' templates/index.html
Expected: no matches in the showAlert method or its callers. (If other unrelated innerHTML uses exist elsewhere in the file, they are out of scope for this task — leave them.)
- Step 6: Run full Python suite to confirm the page still serves
Run: ./run_tests.sh
Expected: All tests pass, including test_root_serves_csp_with_nonce from Task 1.
- Step 7: Manual browser verification
Start the dev server: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
Open http://localhost:8000 and verify:
-
The page loads with no browser console errors about CSP violations.
-
The Bootstrap styling still renders (CSS from cdn.jsdelivr.net loads).
-
Click "Fetch Bitcoin Price" — the success alert shows 1 Bitcoin (BTC) = CHF ... in bold.
-
(If you have OIDC credentials) Log in, click "Refresh" on the Secret Phrase card — the success alert shows Secret Phrase: in bold followed by the phrase text.
-
Trigger an error (e.g., click "Validate Token" with no token) — the error alert renders the message as plain text.
-
Step 8: Commit
git add templates/index.html
git commit -m "fix(security): remove innerHTML XSS sink in showAlert"
Summary
| Task | What | Verification |
|---|---|---|
| 1 | CSP header + nonce injection in read_root() |
pytest (automated) |
| 2 | showAlert DOM rewrite + 3 caller updates |
grep + manual browser check |
After both tasks, the finding is resolved: the innerHTML sink is structurally gone, and a strict nonce-based CSP blocks inline script execution as defense-in-depth.