Compare commits
11 Commits
c960cee268
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2502f005e3
|
|||
|
5aca0038b6
|
|||
|
24417bc7cf
|
|||
|
9c7f2bde11
|
|||
|
1a807cfeb4
|
|||
|
284e685ed3
|
|||
|
d8c947f88f
|
|||
|
83eb6fff89
|
|||
|
ea6955f7fb
|
|||
|
b7a887b68b
|
|||
|
baef35115a
|
+5
-1
@@ -131,4 +131,8 @@ dmypy.json
|
||||
.idea/
|
||||
|
||||
# Additional files from existing .gitignore
|
||||
.envrc
|
||||
.envrc
|
||||
|
||||
# Tailwind CSS generated artifacts
|
||||
style.css
|
||||
tailwindcss
|
||||
@@ -0,0 +1,65 @@
|
||||
# AGENTS.md
|
||||
|
||||
Compact operational guide for OpenCode sessions. See `CLAUDE.md` for the prose
|
||||
overview; this file captures what an agent would otherwise get wrong.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# First-time test env (creates venv/, installs requirements.txt + requirements-test.txt)
|
||||
./setup_test_env.sh
|
||||
|
||||
# Full suite with coverage (activates venv, erases old coverage, builds htmlcov/)
|
||||
./run_tests.sh
|
||||
|
||||
# Manual, from an active venv
|
||||
python -m pytest tests/ --cov=. --cov-config=.coveragerc -v
|
||||
|
||||
# Single test
|
||||
python -m pytest tests/test_main.py::test_health_check -v
|
||||
|
||||
# Dev server (must run from repo root — see Gotchas)
|
||||
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
There is no lint, typecheck, formatter, or CI config in this repo. Do not
|
||||
claim to run `ruff`, `mypy`, `flake8`, or `black` — none are installed or
|
||||
configured. The only verification step is the pytest suite above.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Run from repo root.** `read_root()` and `favicon()` open
|
||||
`templates/index.html` / `templates/favicon.ico` via relative paths
|
||||
(`main.py:329`, `main.py:371`). Invoking uvicorn from another cwd 404s the
|
||||
frontend.
|
||||
- **`@lru_cache` on OIDC helpers.** `get_well_known_config`, `get_jwks`,
|
||||
`get_userinfo_endpoint`, `get_token_endpoint` are all `lru_cache(maxsize=1)`
|
||||
(`main.py:68-129`). When a test patches `requests` with a different
|
||||
response, call `func.cache_clear()` first or you get the previous mock's
|
||||
cached value. Existing tests do this; new tests must too.
|
||||
- **Python 3.14, not 3.7.** `.python-version` and the Dockerfile
|
||||
(`python:3.14-alpine`) target 3.14. The README's "3.7+" claim is stale.
|
||||
- **`.envrc` is gitignored and ships real credentials.** It exports a live
|
||||
`CLIENT_ID` / `CLIENT_SECRET` for direnv. Never commit it, never paste its
|
||||
values into code or commits.
|
||||
- **`CLIENT_ID` has no default.** `main.py:28` reads it from env with no
|
||||
fallback; the app and several tests need it set. `OIDC_ISSUER` defaults to
|
||||
`https://login.infomaniak.com`; `CLIENT_SECRET` defaults to `""` (breaks
|
||||
`/refresh-token` with "Client secret not configured").
|
||||
|
||||
## Architecture essentials
|
||||
|
||||
- Single-file backend `main.py` (FastAPI) + single-file frontend
|
||||
`templates/index.html`. No package layout, no `pyproject.toml`, no
|
||||
`setup.cfg`.
|
||||
- `AUTHORIZED_USERS` (`main.py:33`) is a hardcoded `{email: secret_phrase}`
|
||||
dict — the "protected resource". Not a database.
|
||||
- Token validation: `verify_token(id_token, expected_nonce=None)` does JWKS
|
||||
signature verification + audience/issuer checks. The `expected_nonce`
|
||||
param binds the id_token to the login flow (anti-replay); `/validate-token`
|
||||
forwards the nonce from the request body (`main.py:278`). Tests in
|
||||
`test_token_validation.py` and `test_main.py` cover the nonce paths — keep
|
||||
them green when touching auth.
|
||||
- Tests use `fastapi.testclient.TestClient` (sync, backed by `httpx`) and
|
||||
`unittest.mock.patch` against `main.*`. Fixtures live in `tests/conftest.py`.
|
||||
- Coverage config (`.coveragerc`) omits `venv/`, `tests/`, `.venv/`.
|
||||
+15
-2
@@ -1,5 +1,5 @@
|
||||
# Multi-stage build for smaller image size
|
||||
FROM python:3.9-alpine AS python-base
|
||||
FROM python:3.14-alpine AS python-base
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache curl
|
||||
@@ -23,18 +23,31 @@ RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Build Tailwind CSS
|
||||
COPY templates/index.html templates/index.html
|
||||
COPY style-input.css tailwind.config.js ./
|
||||
RUN case "$(uname -m)" in \
|
||||
x86_64) arch="x64" ;; \
|
||||
aarch64|arm64) arch="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $(uname -m)"; exit 1 ;; \
|
||||
esac \
|
||||
&& curl -sL "https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-${arch}" -o tailwindcss \
|
||||
&& chmod +x tailwindcss \
|
||||
&& ./tailwindcss -i ./style-input.css -o ./style.css --minify
|
||||
|
||||
#############################################
|
||||
# Final stage
|
||||
#############################################
|
||||
FROM python-base
|
||||
|
||||
# Copy installed Python packages and binaries from builder stage
|
||||
COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages
|
||||
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
|
||||
# Copy application files
|
||||
COPY main.py .
|
||||
COPY templates/ templates/
|
||||
COPY --from=builder /app/style.css ./style.css
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown -R app:app /app
|
||||
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build CSS using Tailwind CSS standalone CLI
|
||||
# For local development, run:
|
||||
# ./build-css.sh # one-off build
|
||||
# ./build-css.sh --watch # watch mode
|
||||
|
||||
version="v3.4.17"
|
||||
os="$(uname -s)"
|
||||
arch="$(uname -m)"
|
||||
|
||||
case "$os" in
|
||||
Linux*) platform="linux";;
|
||||
Darwin*) platform="macos";;
|
||||
*) echo "Unsupported OS: $os"; exit 1;;
|
||||
esac
|
||||
|
||||
case "$arch" in
|
||||
x86_64) arch="x64";;
|
||||
arm64) arch="arm64";;
|
||||
aarch64) arch="arm64";;
|
||||
*) echo "Unsupported architecture: $arch"; exit 1;;
|
||||
esac
|
||||
|
||||
binary="tailwindcss-${platform}-${arch}"
|
||||
url="https://github.com/tailwindlabs/tailwindcss/releases/download/${version}/${binary}"
|
||||
|
||||
if [ ! -f "tailwindcss" ]; then
|
||||
echo "Downloading Tailwind CSS standalone CLI (${version} ${platform}/${arch})..."
|
||||
curl -sL "$url" -o tailwindcss
|
||||
chmod +x tailwindcss
|
||||
fi
|
||||
|
||||
if [ "${1:-}" == "--watch" ]; then
|
||||
echo "Building CSS in watch mode..."
|
||||
./tailwindcss -i ./style-input.css -o ./style.css --watch
|
||||
else
|
||||
echo "Building CSS..."
|
||||
./tailwindcss -i ./style-input.css -o ./style.css --minify
|
||||
fi
|
||||
@@ -0,0 +1,109 @@
|
||||
# Python 3.14 + Dependency Update 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:** Update the project to run on Python 3.14 with up-to-date dependencies, removing the unused `python-jose`.
|
||||
|
||||
**Architecture:** Pure config/dependency bump. No application code changes. Verification is by running the existing test suite under a fresh Python 3.14 venv with the bumped dependencies, plus a Docker build.
|
||||
|
||||
**Tech Stack:** Python 3.14, FastAPI, uvicorn, pyjwt, requests, cryptography, pytest, Docker (alpine).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python target: 3.14 (local interpreter at `/opt/homebrew/bin/python3.14`, version 3.14.6)
|
||||
- Version specifier style: `>=` lower-bound floors (no exact pinning)
|
||||
- Drop `python-jose` entirely (unused, unmaintained)
|
||||
- No changes to `main.py`, `generate_favicon.py`, templates, or the `code/`/`source/` duplicate dirs
|
||||
- Keep alpine base image, multi-stage build, non-root user, healthcheck, and existing CMD in Dockerfile
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Bump dependencies, fix Dockerfile, add .python-version
|
||||
|
||||
**Files:**
|
||||
- Modify: `requirements.txt`
|
||||
- Modify: `requirements-test.txt`
|
||||
- Modify: `Dockerfile` (lines 2 and 32)
|
||||
- Create: `.python-version`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (first task)
|
||||
- Produces: a Python 3.14-compatible dependency set and Docker image
|
||||
|
||||
- [ ] **Step 1: Replace `requirements.txt` contents**
|
||||
|
||||
```
|
||||
fastapi>=0.115.0
|
||||
uvicorn>=0.30.0
|
||||
pyjwt>=2.9.0
|
||||
requests>=2.32.0
|
||||
cryptography>=44.0.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `requirements-test.txt` contents**
|
||||
|
||||
```
|
||||
pytest>=8.3.0
|
||||
pytest-cov>=5.0.0
|
||||
coverage>=7.4.0
|
||||
httpx>=0.27.0
|
||||
asgi_lifespan>=2.0.0
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Edit `Dockerfile` line 2 — bump base image**
|
||||
|
||||
Change:
|
||||
```
|
||||
FROM python:3.9-alpine AS python-base
|
||||
```
|
||||
To:
|
||||
```
|
||||
FROM python:3.14-alpine AS python-base
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Edit `Dockerfile` line 32 — fix hardcoded site-packages path**
|
||||
|
||||
Change:
|
||||
```
|
||||
COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages
|
||||
```
|
||||
To:
|
||||
```
|
||||
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create `.python-version`**
|
||||
|
||||
Contents:
|
||||
```
|
||||
3.14
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Recreate venv and install deps**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
rm -rf venv
|
||||
python3.14 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
```
|
||||
Expected: exit 0, no errors. cryptography should install (wheel preferred; build deps present in Dockerfile for the container case).
|
||||
|
||||
- [ ] **Step 7: Run the test suite**
|
||||
|
||||
Run: `./run_tests.sh`
|
||||
Expected: all tests pass, exit 0, coverage report generated in `htmlcov/`. No new failures vs. baseline.
|
||||
|
||||
- [ ] **Step 8: Verify Docker build (if Docker is available)**
|
||||
|
||||
Run: `docker build -t oidc-validator .`
|
||||
Expected: exit 0. (If Docker is not running/installed, note this and skip — it is not a blocker for the local change.)
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add requirements.txt requirements-test.txt Dockerfile .python-version
|
||||
git commit -m "build: bump to Python 3.14 and update dependencies"
|
||||
```
|
||||
@@ -0,0 +1,262 @@
|
||||
# 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.html` in `main.py:329`).
|
||||
- Python 3.14 (`.python-version`, Dockerfile `python: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_cache` OIDC helpers (`main.py:68-129`) — call `func.cache_clear()` when patching `requests`. Not relevant to this plan but good to know.
|
||||
- `CLIENT_ID` has no default (`main.py:28`) — the test env sets it via `run_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` (add `import secrets`)
|
||||
- Modify: `main.py:326-331` (`read_root` function)
|
||||
- Test: `tests/test_main.py` (add new test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: none
|
||||
- Produces: `GET /` now returns a `Content-Security-Policy` response header containing a fresh per-request nonce, and the HTML body's inline `<script>` tag carries a matching `nonce="..."` attribute.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add this test to `tests/test_main.py` (after `test_get_client_config`, around line 33):
|
||||
|
||||
```python
|
||||
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 secrets` to `main.py`**
|
||||
|
||||
In `main.py`, add `import secrets` after `import os` (line 9), keeping it in the stdlib group:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
@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**
|
||||
|
||||
```bash
|
||||
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` (`showAlert` method)
|
||||
- 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 plain `string` (rendered as `textContent`) or a structured `{title, body}` object (title rendered in `<strong>` as `textContent`, body rendered as text). The `innerHTML` sink 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 `showAlert` to build DOM instead of using `innerHTML`**
|
||||
|
||||
In `templates/index.html`, replace the `showAlert` method (lines 162-164):
|
||||
|
||||
Current:
|
||||
```js
|
||||
showAlert(element, message, type = 'info') {
|
||||
element.innerHTML = `<div class="alert alert-${type}" role="alert">${message}</div>`;
|
||||
},
|
||||
```
|
||||
|
||||
New:
|
||||
```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);
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update caller at line 468 (cached secret phrase)**
|
||||
|
||||
Current (`templates/index.html:468`):
|
||||
```js
|
||||
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${cachedSecret}`, 'success');
|
||||
```
|
||||
|
||||
New:
|
||||
```js
|
||||
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`):
|
||||
```js
|
||||
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${data.secret_phrase}`, 'success');
|
||||
```
|
||||
|
||||
New:
|
||||
```js
|
||||
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`):
|
||||
```js
|
||||
utils.showAlert(
|
||||
elements.btcPriceResult,
|
||||
`<strong>1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}</strong>`,
|
||||
'success'
|
||||
);
|
||||
```
|
||||
|
||||
New:
|
||||
```js
|
||||
utils.showAlert(
|
||||
elements.btcPriceResult,
|
||||
{ title: `1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}` },
|
||||
'success'
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify no `innerHTML` remains in `showAlert` or 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**
|
||||
|
||||
```bash
|
||||
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.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Python 3.14 + Dependency Update — Design
|
||||
|
||||
**Date:** 2026-07-08
|
||||
**Status:** Approved
|
||||
**Author:** brainstorming session
|
||||
|
||||
## Goal
|
||||
|
||||
Update the project to run on Python 3.14 and bring all dependencies up to recent stable versions. No functional changes to application behavior.
|
||||
|
||||
## Background
|
||||
|
||||
- The local interpreter is already Python 3.14.6 (`/opt/homebrew/bin/python3.14`).
|
||||
- `Dockerfile` pins `python:3.9-alpine` and hardcodes `/usr/local/lib/python3.9/site-packages` in the final stage — a latent bug that breaks on any version bump.
|
||||
- `python-jose` is declared in `requirements.txt` but never imported anywhere in the codebase (verified by grep across all `*.py`). It is effectively unmaintained (last release 3.5.0).
|
||||
- `main.py` already runs fine under pydantic v2 and recent FastAPI on the local 3.14 venv; no code changes are required for the version bumps.
|
||||
- The repo contains pre-existing duplicate trees (`code/`, `source/`) that are out of scope.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
- `requirements.txt`
|
||||
- `requirements-test.txt`
|
||||
- `Dockerfile`
|
||||
- New `.python-version` file
|
||||
|
||||
**Out of scope:**
|
||||
- Any code changes to `main.py`, `generate_favicon.py`, or templates.
|
||||
- Type-hint modernization (e.g. `Optional[X]` → `X | None`). Not caused by this change.
|
||||
- Removal of unused imports in `main.py` (`HTTPException`, `Header`, `StaticFiles`). Pre-existing dead code.
|
||||
- The `code/` and `source/` duplicate directories.
|
||||
- Switching the Docker base image from alpine to slim. Considered and rejected — alpine is the existing convention and keeps the diff minimal.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Dependency specifier strategy
|
||||
Keep the existing `>=` lower-bound convention, but bump floors to recent stable majors. Rationale: matches existing convention, allows automatic patch/minor updates, avoids the maintenance burden of exact pinning.
|
||||
|
||||
### `python-jose` removal
|
||||
Drop `python-jose` from `requirements.txt`. Rationale: unused (no imports anywhere), unmaintained, reduces install time and attack surface. All JWT operations use `pyjwt`.
|
||||
|
||||
### Dockerfile base image
|
||||
`python:3.9-alpine` → `python:3.14-alpine`. Keeps the alpine + multi-stage + non-root-user convention. Only changes that are forced by the version bump will be touched (base image tag + the two hardcoded `python3.9` path references).
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. `requirements.txt`
|
||||
|
||||
Replace contents with:
|
||||
|
||||
```
|
||||
fastapi>=0.115.0
|
||||
uvicorn>=0.30.0
|
||||
pyjwt>=2.9.0
|
||||
requests>=2.32.0
|
||||
cryptography>=44.0.0
|
||||
```
|
||||
|
||||
(`python-jose` line removed.)
|
||||
|
||||
### 2. `requirements-test.txt`
|
||||
|
||||
Replace contents with:
|
||||
|
||||
```
|
||||
pytest>=8.3.0
|
||||
pytest-cov>=5.0.0
|
||||
coverage>=7.4.0
|
||||
httpx>=0.27.0
|
||||
asgi_lifespan>=2.0.0
|
||||
```
|
||||
|
||||
### 3. `Dockerfile`
|
||||
|
||||
Two surgical edits:
|
||||
- Line 2: `FROM python:3.9-alpine AS python-base` → `FROM python:3.14-alpine AS python-base`
|
||||
- Line 32: `COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages` → `python3.14` in both path segments.
|
||||
|
||||
Everything else in the Dockerfile stays unchanged: alpine packages (`curl`, `gcc musl-dev libffi-dev openssl-dev`), non-root user, port, healthcheck, CMD.
|
||||
|
||||
### 4. `.python-version` (new file)
|
||||
|
||||
Contents: `3.14`
|
||||
|
||||
Purpose: signals the intended interpreter to `pyenv`-aware tooling and documents the target version for local development.
|
||||
|
||||
## Verification
|
||||
|
||||
Success criteria — all must pass:
|
||||
|
||||
1. **Venv install:** Recreate `venv/` with `python3.14 -m venv venv`, `pip install -r requirements.txt -r requirements-test.txt` exits 0.
|
||||
2. **Test suite:** `./run_tests.sh` exits 0 with the same (or better) coverage as before. No new failures.
|
||||
3. **Docker build (if available):** `docker build -t oidc-validator .` exits 0.
|
||||
|
||||
## Risks
|
||||
|
||||
- **cryptography build from source on alpine:** mitigated by the existing `gcc musl-dev libffi-dev openssl-dev` build-deps line. cryptography 44+ ships musllinux wheels for 3.14, so a wheel should be available; if not, the build deps cover it.
|
||||
- **New dependency majors changing behavior:** FastAPI 0.115 and pydantic v2 are already running locally without issue; no code changes observed as necessary. If tests surface breakage, it will be caught by the verification step and addressed then.
|
||||
@@ -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 `<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`.
|
||||
|
||||
```python
|
||||
@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.html` — `showAlert` rewrite + 3 caller updates.
|
||||
- `main.py` — `import 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.
|
||||
@@ -7,6 +7,7 @@ from typing import Optional
|
||||
import jwt
|
||||
import requests
|
||||
import os
|
||||
import secrets
|
||||
from functools import lru_cache
|
||||
|
||||
app = FastAPI(title="OIDC Token Validator")
|
||||
@@ -38,6 +39,7 @@ AUTHORIZED_USERS = {
|
||||
class TokenValidationRequest(BaseModel):
|
||||
id_token: str
|
||||
access_token: Optional[str] = None
|
||||
nonce: Optional[str] = None
|
||||
|
||||
class TokenRefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
@@ -204,8 +206,13 @@ def refresh_token(refresh_token: str):
|
||||
except Exception as e:
|
||||
raise ValueError(f"Token refresh failed: {str(e)}")
|
||||
|
||||
def verify_token(id_token: str):
|
||||
"""Verify JWT token and return payload if valid"""
|
||||
def verify_token(id_token: str, expected_nonce: Optional[str] = None):
|
||||
"""Verify JWT token and return payload if valid
|
||||
|
||||
When expected_nonce is provided, the id_token's nonce claim must match it.
|
||||
This binds the token to the OIDC login flow that minted it (anti-replay /
|
||||
anti-forced-authentication), complementing the client-side state/nonce checks.
|
||||
"""
|
||||
try:
|
||||
# Validate input
|
||||
if not id_token or not isinstance(id_token, str):
|
||||
@@ -243,6 +250,14 @@ def verify_token(id_token: str):
|
||||
issuer=ISSUER
|
||||
)
|
||||
|
||||
# Validate nonce binding when an expected nonce is supplied
|
||||
if expected_nonce is not None:
|
||||
token_nonce = payload.get("nonce")
|
||||
if not token_nonce:
|
||||
raise ValueError("Token missing nonce claim")
|
||||
if token_nonce != expected_nonce:
|
||||
raise ValueError("Nonce mismatch: token is not bound to this login")
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise ValueError("Token has expired")
|
||||
@@ -261,7 +276,7 @@ async def validate_token(request: TokenValidationRequest):
|
||||
|
||||
try:
|
||||
# Verify token
|
||||
payload = verify_token(request.id_token)
|
||||
payload = verify_token(request.id_token, request.nonce)
|
||||
|
||||
# Extract user email
|
||||
user_email = payload.get("email")
|
||||
@@ -312,9 +327,27 @@ async def validate_token(request: TokenValidationRequest):
|
||||
@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()
|
||||
return HTMLResponse(content=html_content, status_code=200)
|
||||
html_content = html_content.replace(
|
||||
'<script>', f'<script nonce="{nonce}">', 1
|
||||
)
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
f"script-src 'self' 'nonce-{nonce}'; "
|
||||
"style-src 'self'; "
|
||||
"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},
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
@@ -356,6 +389,11 @@ async def favicon():
|
||||
"""Serve the favicon"""
|
||||
return FileResponse("templates/favicon.ico")
|
||||
|
||||
@app.get("/style.css")
|
||||
async def stylesheet():
|
||||
"""Serve the Tailwind-generated stylesheet"""
|
||||
return FileResponse("style.css", media_type="text/css")
|
||||
|
||||
@app.get("/config", response_model=ClientConfig)
|
||||
async def get_client_config():
|
||||
"""Serve client configuration to frontend"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pytest>=6.2.0
|
||||
pytest-cov>=2.12.0
|
||||
coverage>=5.5
|
||||
httpx>=0.18.0
|
||||
asgi_lifespan>=1.0.1
|
||||
pytest>=8.3.0
|
||||
pytest-cov>=5.0.0
|
||||
coverage>=7.4.0
|
||||
httpx>=0.27.0
|
||||
asgi_lifespan>=2.0.0
|
||||
|
||||
+5
-6
@@ -1,6 +1,5 @@
|
||||
fastapi>=0.68.0
|
||||
uvicorn>=0.15.0
|
||||
pyjwt>=2.4.0
|
||||
requests>=2.25.0
|
||||
cryptography>=3.4.8
|
||||
python-jose>=3.3.0
|
||||
fastapi>=0.115.0
|
||||
uvicorn>=0.30.0
|
||||
pyjwt>=2.9.0
|
||||
requests>=2.32.0
|
||||
cryptography>=44.0.0
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./templates/index.html"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
+116
-41
@@ -4,54 +4,53 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OIDC Login</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1 class="text-center h3">OIDC Login</h1>
|
||||
<div class="container mx-auto mt-5 px-4">
|
||||
<div class="flex justify-center">
|
||||
<div class="w-full md:w-1/2">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<h1 class="text-center text-2xl font-bold">OIDC Login</h1>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-4">
|
||||
<!-- Login Section -->
|
||||
<section id="login-section" class="text-center">
|
||||
<p>Login with your Infomaniak account</p>
|
||||
<button id="login-btn" class="btn btn-primary w-100" aria-label="Login with Infomaniak">Login with Infomaniak</button>
|
||||
<button id="login-btn" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition" aria-label="Login with Infomaniak">Login with Infomaniak</button>
|
||||
</section>
|
||||
|
||||
<!-- User Info Section -->
|
||||
<section id="user-info" class="d-none">
|
||||
<section id="user-info" class="hidden">
|
||||
<div class="mb-4">
|
||||
<h2 class="h5">Welcome, <span id="user-name">User</span>!</h2>
|
||||
<h2 class="text-xl font-bold">Welcome, <span id="user-name">User</span>!</h2>
|
||||
<p class="mb-0">Email: <span id="user-email"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- Secret Phrase Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="h5 mb-0">Secret Phrase</h3>
|
||||
<button id="refresh-secret-btn" class="btn btn-secondary btn-sm" aria-label="Refresh secret phrase">Refresh</button>
|
||||
<div class="bg-white rounded-lg shadow-md mb-4 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
|
||||
<h3 class="text-xl font-bold mb-0">Secret Phrase</h3>
|
||||
<button id="refresh-secret-btn" class="bg-gray-500 hover:bg-gray-600 text-white text-sm py-1 px-2 rounded transition" aria-label="Refresh secret phrase">Refresh</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-4">
|
||||
<div id="secret-result" class="mb-0">Checking for secret phrase...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bitcoin Price Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="h5 mb-0">Bitcoin Price in CHF</h3>
|
||||
<div class="bg-white rounded-lg shadow-md mb-4 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xl font-bold mb-0">Bitcoin Price in CHF</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button id="fetch-btc-btn" class="btn btn-success mb-3" aria-label="Fetch Bitcoin Price">Fetch Bitcoin Price</button>
|
||||
<div class="p-4">
|
||||
<button id="fetch-btc-btn" class="bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded mb-3 transition" aria-label="Fetch Bitcoin Price">Fetch Bitcoin Price</button>
|
||||
<div id="btc-price-result" class="mb-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="logout-btn" class="btn btn-danger w-100" aria-label="Logout">Logout</button>
|
||||
<button id="logout-btn" class="w-full bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded transition" aria-label="Logout">Logout</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,21 +130,61 @@
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a nonce for security
|
||||
* Generate a cryptographically secure nonce for OIDC anti-replay
|
||||
* @returns {string} Nonce string
|
||||
*/
|
||||
generateNonce() {
|
||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
return utils.generateRandomString(32);
|
||||
},
|
||||
|
||||
/**
|
||||
* Decode a JWT payload without verification (client-side nonce check only)
|
||||
* @param {string} token - JWT token
|
||||
* @returns {Object|null} Decoded payload or null if malformed
|
||||
*/
|
||||
decodeJwtPayload(token) {
|
||||
try {
|
||||
const part = token.split('.')[1];
|
||||
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an alert message in the specified element
|
||||
* @param {HTMLElement} element - Element to show the message in
|
||||
* @param {string} message - Message to display
|
||||
* @param {string} type - Bootstrap alert type (success, danger, warning, info)
|
||||
* @param {string} type - Alert type (success, danger, warning, info)
|
||||
*/
|
||||
showAlert(element, message, type = 'info') {
|
||||
element.innerHTML = `<div class="alert alert-${type}" role="alert">${message}</div>`;
|
||||
const alertClasses = {
|
||||
success: 'bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded',
|
||||
danger: 'bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded',
|
||||
warning: 'bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded',
|
||||
info: 'bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded'
|
||||
};
|
||||
element.replaceChildren();
|
||||
const alert = document.createElement('div');
|
||||
alert.className = alertClasses[type] || alertClasses.info;
|
||||
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);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -197,9 +236,11 @@
|
||||
*/
|
||||
async login() {
|
||||
const nonce = utils.generateNonce();
|
||||
const state = utils.generateRandomString(32);
|
||||
|
||||
// Store nonce for later use
|
||||
// Store nonce and state for later validation (anti-replay / anti-CSRF)
|
||||
localStorage.setItem('nonce', nonce);
|
||||
localStorage.setItem('oidc_state', state);
|
||||
|
||||
// Build authorization URL for implicit flow
|
||||
const authUrl = new URL(CONFIG.issuer + '/authorize');
|
||||
@@ -208,6 +249,7 @@
|
||||
authUrl.searchParams.append('response_type', 'id_token token');
|
||||
authUrl.searchParams.append('scope', CONFIG.scopes);
|
||||
authUrl.searchParams.append('nonce', nonce);
|
||||
authUrl.searchParams.append('state', state);
|
||||
|
||||
// Redirect to authorization server
|
||||
window.location.href = authUrl.toString();
|
||||
@@ -224,6 +266,7 @@
|
||||
const accessToken = urlParams.get('access_token');
|
||||
const idToken = urlParams.get('id_token');
|
||||
const refreshToken = urlParams.get('refresh_token');
|
||||
const state = urlParams.get('state');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
@@ -232,7 +275,29 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Anti-CSRF: validate state binds this callback to a user-initiated login
|
||||
const storedState = localStorage.getItem('oidc_state');
|
||||
if (!state || !storedState || state !== storedState) {
|
||||
alert('Security error: invalid or missing state parameter. Login aborted.');
|
||||
localStorage.removeItem('oidc_state');
|
||||
localStorage.removeItem('nonce');
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessToken && idToken) {
|
||||
// Anti-replay / anti-forced-auth: validate the id_token nonce matches the one we sent
|
||||
const storedNonce = localStorage.getItem('nonce');
|
||||
const payload = utils.decodeJwtPayload(idToken);
|
||||
const tokenNonce = payload ? payload.nonce : null;
|
||||
if (!storedNonce || !tokenNonce || tokenNonce !== storedNonce) {
|
||||
alert('Security error: nonce mismatch. Login aborted to prevent session fixation.');
|
||||
localStorage.removeItem('oidc_state');
|
||||
localStorage.removeItem('nonce');
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear URL fragment
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
@@ -244,8 +309,9 @@
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
localStorage.removeItem('nonce');
|
||||
// Keep nonce in storage so it can be sent to /validate-token
|
||||
// (cleared after validation completes in handleValidationResponse)
|
||||
localStorage.removeItem('oidc_state');
|
||||
|
||||
// Get user info from backend after a short delay
|
||||
// Only call getSecretPhrase if we don't already have a recent cached secret
|
||||
@@ -351,6 +417,7 @@
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_data');
|
||||
localStorage.removeItem('nonce');
|
||||
localStorage.removeItem('oidc_state');
|
||||
localStorage.removeItem('secret_phrase');
|
||||
localStorage.removeItem('secret_last_fetch');
|
||||
|
||||
@@ -365,8 +432,8 @@
|
||||
* @param {Object} user - User object with email, first_name, last_name
|
||||
*/
|
||||
showUserInfo(user) {
|
||||
elements.loginSection.classList.add('d-none');
|
||||
elements.userInfo.classList.remove('d-none');
|
||||
elements.loginSection.classList.add('hidden');
|
||||
elements.userInfo.classList.remove('hidden');
|
||||
|
||||
// Display user's full name
|
||||
elements.userName.textContent = utils.getUserDisplayName(user);
|
||||
@@ -379,8 +446,8 @@
|
||||
* Hide user information section
|
||||
*/
|
||||
hideUserInfo() {
|
||||
elements.loginSection.classList.remove('d-none');
|
||||
elements.userInfo.classList.add('d-none');
|
||||
elements.loginSection.classList.remove('hidden');
|
||||
elements.userInfo.classList.add('hidden');
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -422,7 +489,7 @@
|
||||
ui.showUserInfo(user);
|
||||
}
|
||||
|
||||
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${cachedSecret}`, 'success');
|
||||
utils.showAlert(elements.secretResult, { title: 'Secret Phrase:', body: cachedSecret }, 'success');
|
||||
} else {
|
||||
// Fetch fresh secret phrase and user info
|
||||
setTimeout(ui.getSecretPhrase, 500);
|
||||
@@ -448,6 +515,7 @@
|
||||
|
||||
const idToken = localStorage.getItem('id_token');
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const nonce = localStorage.getItem('nonce');
|
||||
|
||||
if (!idToken) {
|
||||
utils.showAlert(elements.secretResult, 'No ID token found. Please log in first.', 'danger');
|
||||
@@ -464,7 +532,8 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id_token: idToken,
|
||||
access_token: accessToken
|
||||
access_token: accessToken,
|
||||
nonce: nonce || undefined
|
||||
})
|
||||
});
|
||||
|
||||
@@ -476,6 +545,7 @@
|
||||
// Retry the request with refreshed tokens
|
||||
const refreshedIdToken = localStorage.getItem('id_token');
|
||||
const refreshedAccessToken = localStorage.getItem('access_token');
|
||||
const refreshedNonce = localStorage.getItem('nonce');
|
||||
|
||||
const retryResponse = await fetch('/validate-token', {
|
||||
method: 'POST',
|
||||
@@ -484,7 +554,8 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id_token: refreshedIdToken,
|
||||
access_token: refreshedAccessToken
|
||||
access_token: refreshedAccessToken,
|
||||
nonce: refreshedNonce || undefined
|
||||
})
|
||||
});
|
||||
|
||||
@@ -518,6 +589,10 @@
|
||||
* @param {Object} data - Response data from validation endpoint
|
||||
*/
|
||||
async handleValidationResponse(data) {
|
||||
// Nonce is single-use for the login flow; clear it now that validation is done
|
||||
localStorage.removeItem('nonce');
|
||||
localStorage.removeItem('oidc_state');
|
||||
|
||||
if (data.valid) {
|
||||
// Store user info
|
||||
if (data.user) {
|
||||
@@ -530,7 +605,7 @@
|
||||
localStorage.setItem('secret_phrase', data.secret_phrase);
|
||||
localStorage.setItem('secret_last_fetch', Date.now().toString());
|
||||
|
||||
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${data.secret_phrase}`, 'success');
|
||||
utils.showAlert(elements.secretResult, { title: 'Secret Phrase:', body: data.secret_phrase }, 'success');
|
||||
} else {
|
||||
// Clear cached secret if user is no longer authorized
|
||||
localStorage.removeItem('secret_phrase');
|
||||
@@ -566,8 +641,8 @@
|
||||
if (data && data.bitcoin && data.bitcoin.chf) {
|
||||
const price = data.bitcoin.chf;
|
||||
utils.showAlert(
|
||||
elements.btcPriceResult,
|
||||
`<strong>1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}</strong>`,
|
||||
elements.btcPriceResult,
|
||||
{ title: `1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}` },
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
@@ -629,4 +704,4 @@
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -31,6 +31,39 @@ def test_get_client_config():
|
||||
assert isinstance(data["client_id"], str)
|
||||
assert len(data["client_id"]) > 0
|
||||
|
||||
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" not 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
|
||||
|
||||
def test_root_csp_nonce_is_unique_per_request():
|
||||
"""Test that each GET / response gets a fresh CSP nonce (not module-level/static)."""
|
||||
import re
|
||||
n1 = re.search(
|
||||
r"nonce-([A-Za-z0-9_-]+)",
|
||||
client.get("/").headers.get("content-security-policy", ""),
|
||||
).group(1)
|
||||
n2 = re.search(
|
||||
r"nonce-([A-Za-z0-9_-]+)",
|
||||
client.get("/").headers.get("content-security-policy", ""),
|
||||
).group(1)
|
||||
assert n1 != n2
|
||||
|
||||
@patch('main.verify_token')
|
||||
def test_validate_token_success(mock_verify_token):
|
||||
"""Test successful token validation."""
|
||||
@@ -106,6 +139,41 @@ def test_validate_token_invalid(mock_verify_token):
|
||||
assert not data["valid"]
|
||||
assert data["error"] == "Invalid token"
|
||||
|
||||
@patch('main.verify_token')
|
||||
def test_validate_token_passes_nonce_to_verify(mock_verify_token):
|
||||
"""The endpoint must forward the supplied nonce to verify_token (anti-replay binding)."""
|
||||
mock_verify_token.return_value = {
|
||||
"email": "rene.luria@infomaniak.com",
|
||||
"iss": "https://login.infomaniak.com",
|
||||
"aud": "test-client-id",
|
||||
"nonce": "abc123",
|
||||
}
|
||||
|
||||
response = client.post("/validate-token", json={
|
||||
"id_token": "valid.token.here",
|
||||
"nonce": "abc123"
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["valid"]
|
||||
# verify_token must have been called with the nonce as the second positional arg
|
||||
assert mock_verify_token.call_args.args[1] == "abc123"
|
||||
|
||||
@patch('main.verify_token')
|
||||
def test_validate_token_rejects_nonce_mismatch(mock_verify_token):
|
||||
"""A mismatched nonce (forced-auth / replay token) is rejected by the backend."""
|
||||
mock_verify_token.side_effect = ValueError("Nonce mismatch: token is not bound to this login")
|
||||
|
||||
response = client.post("/validate-token", json={
|
||||
"id_token": "forged.token.here",
|
||||
"nonce": "abc123"
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert not data["valid"]
|
||||
assert "Nonce mismatch" in data["error"]
|
||||
|
||||
def test_validate_token_empty_request():
|
||||
"""Test validation with empty token."""
|
||||
response = client.post("/validate-token", json={
|
||||
|
||||
@@ -58,6 +58,99 @@ def test_verify_token_no_matching_key(mock_get_header, mock_get_jwks):
|
||||
with pytest.raises(ValueError, match="Unable to find matching key in JWKS"):
|
||||
verify_token("some.token.string")
|
||||
|
||||
|
||||
def _mock_jwks_and_key():
|
||||
"""Helper: return (jwks, header) fixtures for reaching the jwt.decode step."""
|
||||
jwks = {
|
||||
"keys": [
|
||||
{"kid": "test-key-id", "kty": "RSA", "n": "modulus", "e": "AQAB"}
|
||||
]
|
||||
}
|
||||
header = {"kid": "test-key-id", "alg": "RS256"}
|
||||
return jwks, header
|
||||
|
||||
|
||||
@patch('main.jwt.decode')
|
||||
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
|
||||
@patch('main.jwt.get_unverified_header')
|
||||
@patch('main.get_jwks')
|
||||
def test_verify_token_nonce_match(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
|
||||
"""verify_token returns payload when expected_nonce matches the token nonce."""
|
||||
jwks, header = _mock_jwks_and_key()
|
||||
mock_get_jwks.return_value = jwks
|
||||
mock_get_header.return_value = header
|
||||
mock_from_jwk.return_value = "mock-key"
|
||||
mock_decode.return_value = {
|
||||
"iss": "https://login.infomaniak.com",
|
||||
"aud": "test-client-id",
|
||||
"email": "rene.luria@infomaniak.com",
|
||||
"nonce": "abc123",
|
||||
}
|
||||
|
||||
payload = verify_token("some.token.string", expected_nonce="abc123")
|
||||
assert payload["nonce"] == "abc123"
|
||||
|
||||
|
||||
@patch('main.jwt.decode')
|
||||
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
|
||||
@patch('main.jwt.get_unverified_header')
|
||||
@patch('main.get_jwks')
|
||||
def test_verify_token_nonce_mismatch(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
|
||||
"""verify_token rejects an id_token whose nonce does not match the expected one."""
|
||||
jwks, header = _mock_jwks_and_key()
|
||||
mock_get_jwks.return_value = jwks
|
||||
mock_get_header.return_value = header
|
||||
mock_from_jwk.return_value = "mock-key"
|
||||
mock_decode.return_value = {
|
||||
"iss": "https://login.infomaniak.com",
|
||||
"aud": "test-client-id",
|
||||
"email": "attacker@evil.com",
|
||||
"nonce": "VERYWRONG_NONCE",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Nonce mismatch"):
|
||||
verify_token("some.token.string", expected_nonce="abc123")
|
||||
|
||||
|
||||
@patch('main.jwt.decode')
|
||||
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
|
||||
@patch('main.jwt.get_unverified_header')
|
||||
@patch('main.get_jwks')
|
||||
def test_verify_token_nonce_missing_in_token(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
|
||||
"""verify_token rejects a token lacking a nonce claim when an expected nonce is supplied."""
|
||||
jwks, header = _mock_jwks_and_key()
|
||||
mock_get_jwks.return_value = jwks
|
||||
mock_get_header.return_value = header
|
||||
mock_from_jwk.return_value = "mock-key"
|
||||
mock_decode.return_value = {
|
||||
"iss": "https://login.infomaniak.com",
|
||||
"aud": "test-client-id",
|
||||
"email": "rene.luria@infomaniak.com",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Token missing nonce claim"):
|
||||
verify_token("some.token.string", expected_nonce="abc123")
|
||||
|
||||
|
||||
@patch('main.jwt.decode')
|
||||
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
|
||||
@patch('main.jwt.get_unverified_header')
|
||||
@patch('main.get_jwks')
|
||||
def test_verify_token_nonce_not_checked_when_none(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
|
||||
"""verify_token skips the nonce check when no expected_nonce is provided (returning visits / refresh)."""
|
||||
jwks, header = _mock_jwks_and_key()
|
||||
mock_get_jwks.return_value = jwks
|
||||
mock_get_header.return_value = header
|
||||
mock_from_jwk.return_value = "mock-key"
|
||||
mock_decode.return_value = {
|
||||
"iss": "https://login.infomaniak.com",
|
||||
"aud": "test-client-id",
|
||||
"email": "rene.luria@infomaniak.com",
|
||||
}
|
||||
|
||||
payload = verify_token("some.token.string")
|
||||
assert payload["email"] == "rene.luria@infomaniak.com"
|
||||
|
||||
@patch('main.requests.get')
|
||||
def test_get_well_known_config_success(mock_get):
|
||||
"""Test successful retrieval of well-known configuration."""
|
||||
|
||||
Reference in New Issue
Block a user