From 030679d19d4c29f30d524044e41dda9f2e0337b0 Mon Sep 17 00:00:00 2001 From: Kilian Mateo <13885240+kilianmc@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:15:34 +0200 Subject: [PATCH] chore: upgrade FastAPI/Starlette, add security response headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI 0.128.8 -> 0.141.1 and Starlette 0.52.1 -> 1.6.0, which is what the six open Dependabot alerts needed; also pytest 9.1.1, ruff 0.16.3 and uvicorn 0.52.3. Alerts are evaluated against the default branch, so they clear at the first promotion to main, not on this merge. Bumping fastapi alone did not move starlette: uv reported success and left the pinned transitive version in place, so it needed an explicit --upgrade-package. Worth knowing before trusting a lockfile diff. The jump broke the route-enumeration security test silently. Since 0.137 include_router stores a tree node instead of copying routes onto the parent, so walking app.routes returned three routes where it returned nine — and still passed, because a walk over nothing finds no unprotected endpoints. The walk now goes through iter_route_contexts, and a canary asserts a known-protected route is visible in it and absent from PUBLIC_ROUTES, so this file cannot go vacuous unnoticed again. Headers arrive in two layers. vercel.json covers what the CDN serves; a plain ASGI middleware covers /api/* in process, so the set also holds under bare uvicorn and can be asserted in CI. It wraps send rather than subclassing BaseHTTPMiddleware, which is what puts headers on the responses no endpoint produced: 401, 403, 404, 422. The edge rule deliberately overlaps /api/*, because a 500 from an unhandled exception comes from ServerErrorMiddleware, built outside the user middleware stack, and never reaches the wrapper. The document policy carries no unsafe-* token: React sets inline styles through CSSOM, which CSP does not govern, so style-src stays 'self'. Strict-Transport-Security is left to Vercel, which already sends it — a duplicate field is ignored rather than merged. Cross-Origin-Resource- Policy is never set, since same-origin would stop the shell loading remoteEntry.js in PR #5. Permissions-Policy lists only what it restricts, leaving screen-wake-lock, fullscreen and autoplay at their defaults for the session player. The /api/docs CSP exemption is derived from the configured docs URLs rather than written as a literal, so it is empty in production where they are None. Adds .github/dependabot.yml for uv, npm and github-actions, grouped and weekly, with @types/node held at the Node 24 major. Dependabot reads the config from the default branch only, so a byte-identical twin on main is required before it does anything. Co-Authored-By: Claude Opus 5 (1M context) --- .github/dependabot.yml | 50 ++++++++ CLAUDE.md | 171 ++++++++++++++++++++++++++- package.json | 2 +- pyproject.toml | 12 +- server/app.py | 11 ++ server/security_headers.py | 74 ++++++++++++ tests/test_auth_routes_enumerated.py | 37 +++++- tests/test_security_headers.py | 139 ++++++++++++++++++++++ uv.lock | 74 ++++++------ vercel.json | 24 ++++ web/vite.config.ts | 40 +++++++ 11 files changed, 583 insertions(+), 51 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 server/security_headers.py create mode 100644 tests/test_security_headers.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ae3c110 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,50 @@ +version: 2 +updates: + - package-ecosystem: "uv" + directory: "/" + target-branch: "dev" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "chore" + groups: + python: + applies-to: version-updates + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/web" + target-branch: "dev" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "chore" + groups: + web: + applies-to: version-updates + patterns: + - "*" + ignore: + - dependency-name: "@types/node" + update-types: + - "version-update:semver-major" + + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "dev" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "chore" + groups: + actions: + applies-to: version-updates + patterns: + - "*" diff --git a/CLAUDE.md b/CLAUDE.md index a8ee9ca..ad2dff7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -499,6 +499,71 @@ Non-negotiable. The realistic threat is bulk data extraction, not defacement. Public Suffix List, so a preview URL is genuinely **cross-site** and the cookie cannot work there. Previews fall back to demo mode. +### Security response headers (v1.3.0) + +Two delivery points, because their coverage differs: `vercel.json` `headers` for what the +**CDN** serves (SPA document, JS, CSS), and `server/security_headers.py` for **`/api/*` +JSON** — in-process, so it also applies under bare `uvicorn` and is assertable in CI. The +overlap is deliberate: a header set stops being sent without anything failing. + +| Header | Document (`vercel.json`) | `/api/*` (middleware) | +| --- | --- | --- | +| `Strict-Transport-Security` | **not ours** — Vercel sends `max-age=63072000` | same | +| `X-Content-Type-Options` | `nosniff` | `nosniff` | +| `Referrer-Policy` | `no-referrer` | `no-referrer` | +| `X-Frame-Options` | `DENY` | `DENY` | +| `Cross-Origin-Opener-Policy` | `same-origin` | `same-origin` | +| `Permissions-Policy` | deny list below | same | +| `Content-Security-Policy` | document policy below | `default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'` | +| `Cross-Origin-Resource-Policy` / `-Embedder-Policy` | **never set** | **never set** | + +Document CSP, enforcing (not Report-Only): + +```text +default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; +font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; +form-action 'none'; frame-src 'none'; frame-ancestors 'none'; +upgrade-insecure-requests +``` + +- **No `unsafe-*` anywhere, `style-src` included.** React's `style={{…}}` writes through + **CSSOM, which CSP does not govern**; only literal `style=` attributes in served markup + need `'unsafe-inline'`, and SCSS compiles to external files. If something ever does need + it, `vite preview` fails loudly — re-add one token then, with a reason. An `unsafe-*` kept + "just in case" is how a CSP stays permanently weak. +- **`img-src data:`** — Vite inlines assets under 4 KB as `data:` URLs. +- **We do not set HSTS.** Vercel already sends `max-age=63072000` (verified `curl -sI`, + 2026-08-14), and duplicate STS fields are **not merged** — RFC 6797 §8.1 processes only + the first. Cost: it is the one header we do not own, hence item 9. +- **⚠️ Never set `Cross-Origin-Resource-Policy`.** `same-origin` would stop kilianmc.com + loading `/remoteEntry.js` and `/assets/*`. **This, not `frame-ancestors`, is the header + that breaks the federated mount.** `cross-origin` is a no-op, and on `/api/*` a + restrictive value sits in the path of the federated app's credentialed `fetch()` for no + gain over CORS + Bearer. No `Cross-Origin-Embedder-Policy` either — `require-corp` blocks + every cross-origin subresource and nothing needs `SharedArrayBuffer`. No `sandbox` in the + API CSP: without `allow-downloads` it would break a future export endpoint. +- **`Permissions-Policy` lists only what we restrict; unlisted keeps the browser default.** + `camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), + midi=(), display-capture=(), idle-detection=()`. ⚠️ **`screen-wake-lock`, `fullscreen` + and `autoplay` must stay absent** — the session player needs all three. Tested, because + "deny everything unused" is the change that would add them. +- **The `/api/docs` CSP exemption is derived from `app.docs_url` / `app.openapi_url`, never + hardcoded** — both are `None` in production, so the exempt set is empty there. Swagger UI + loads its assets from a CDN that `default-src 'none'` would block. Tested. +- **Middleware ordering:** `add_middleware` prepends, so the last added is outermost. The + headers middleware is added last, outside CORS, and writes only its own header names — + never `Access-Control-*` or `Vary`. Wrapping `send` is what covers the 401 / 403 / 404 / + 422 no endpoint produced. The two layers' coverage is not identical; hence both. +- **`vercel.json`'s `/(.*)` block intentionally overlaps `/api/*`.** Whether Vercel + overwrites or appends on function responses is **unverified** — item 9 must `curl -sI` an + `/api/*` path on the real deploy and check no header appears twice. +- **`vite dev` deliberately gets no CSP** — its inline client and HMR websocket would need a + laxer policy than production, proving less. `vite preview` serves the real build with the + real headers, read out of `vercel.json` by `web/vite.config.ts` so the two cannot drift. +- **For PR #5:** the *shell's* CSP governs the federated mount, not ours. `portfolio-shell` + has none today; if it gains one it needs `script-src` **and** `connect-src` for + `https://climb.kilianmc.com`. + ### Auth implementation (PR #3) — where each piece lives `server/auth/` — `passwords.py`, `tokens.py`, `refresh.py`, `cookies.py`, @@ -599,9 +664,16 @@ against the **production deploy**, not a preview: previews are cross-site 8. **Login rate limit** — it trips; the 429 is byte-identical for a real and a non-existent address; it self-heals within the window and no account is ever disabled. - 9. **Security response headers** — once that PR has landed: HSTS, CSP, - `Referrer-Policy`, `X-Content-Type-Options`, and **verify `frame-ancestors` does not - break the federated mount inside `portfolio-shell`** (this is the one that will). + 9. **Security response headers** — landed in v1.3.0; see the baseline section above. On + the real deploy check that the `vercel.json` layer reaches **`/api/*`** and that no + header (notably `Strict-Transport-Security`) appears **twice**, and that the document + CSP logs no console violations on a real page load. + - **CORRECTED 2026-08-14:** the original wording predicted `frame-ancestors` would + break the federated mount. It cannot. The shell mounts us as a **script** + (`React.lazy(() => import('climbTrainer/App'))`), never in an iframe — verified in + `portfolio-shell/src/components/ProjectViewer.tsx`, whose `