Skip to content

Commit 141f688

Browse files
committed
Add HTTP security headers to API responses and static assets
A security audit found no security headers (CSP, X-Frame-Options, X-Content-Type-Options, etc.) set anywhere in the deployment. - backend/app/main.py: an ASGI middleware sets X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and a path-scoped Content-Security-Policy (strict for JSON API endpoints, a looser CDN/inline-allowing policy for FastAPI's stock /docs and /redoc pages, and a frontend-matching one for the local-dev static-file fallback) on every response. Strict-Transport-Security is added only when SESSION_COOKIE_SECURE is true, so plain-http local dev is unaffected. Verified live against a local uvicorn instance (curl -I on /health, /api/v1/opportunities/stats, /openapi.json, /docs, /redoc, /) and with Playwright against /docs and /redoc confirming no CSP-refusal console errors for their CDN scripts/styles or Swagger's inline init script. - frontend/_headers: Cloudflare Workers' native static-asset header mechanism (confirmed current via the Cloudflare docs MCP tool, not assumed) attaches the same header set at the edge, since the Worker script never runs for matching static files. CSP is scoped per page: index.html allows Google Fonts and its one inline JSON-LD block via a sha256 hash (no unsafe-inline needed for script-src); admin.html has no inline scripts at all. Verified against a real `wrangler dev` locally, including a redirect gotcha it surfaced: /admin.html 307s to the extensionless /admin, so that canonical path needed its own rule too. - docs/DEPLOY-CLOUDFLARE-WORKERS.md: documents both mechanisms and why they're split across two files with no shared config to read from. Full backend pytest suite (316 tests), ruff, and the frontend vitest suite (27 tests) all pass unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Umy9bW14TMbzW2QD7eLt3
1 parent 0982114 commit 141f688

3 files changed

Lines changed: 179 additions & 0 deletions

File tree

backend/app/main.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,98 @@ async def lifespan(app: FastAPI):
6868
allow_headers=["*"],
6969
)
7070

71+
# ---- Security headers (2026-08 audit: none were set anywhere) --------
72+
# Applied to every response via middleware rather than per-route, so a
73+
# new route can never ship without them by omission.
74+
#
75+
# The Content-Security-Policy is path-scoped because this app genuinely
76+
# serves three different kinds of response:
77+
# - JSON API endpoints (/api/*, /health, /openapi.json) render no page
78+
# at all, so they get the strictest possible policy.
79+
# - /docs and /redoc are FastAPI's built-in Swagger UI / ReDoc pages.
80+
# Verified by actually curling both: Swagger UI loads its JS/CSS
81+
# from cdn.jsdelivr.net and runs an inline init `<script>`; ReDoc
82+
# loads its bundle from the same CDN plus a Google Font and injects
83+
# inline `<style>` at runtime (its CSS-in-JS). A strict policy blanks
84+
# both pages out, so they get a scoped, looser policy instead of
85+
# weakening the default for everything else.
86+
# - Everything else falls through to `frontend()` below, which only
87+
# serves the static site directly when this app is run without the
88+
# Cloudflare Worker in front of it (local dev, `docker compose`, or
89+
# a direct hit on the Render origin). Its policy mirrors
90+
# `frontend/_headers`, which is what actually applies in production
91+
# — Cloudflare serves those files at the edge without this app in
92+
# the loop at all.
93+
_DOCS_PATHS = {"/docs", "/docs/oauth2-redirect", "/redoc"}
94+
_API_EXACT_PATHS = {"/health", "/openapi.json", "/api"}
95+
_API_PREFIX = "/api/"
96+
97+
# unsafe-inline is unavoidable here without vendoring/patching FastAPI's
98+
# built-in docs HTML to add a nonce: Swagger's init script is generated
99+
# fresh per request and ReDoc injects styles at runtime, so neither a
100+
# static hash nor a nonce (no per-request templating happens for these
101+
# stock responses) is workable. Scoped to just these two paths so it
102+
# never leaks into the API or frontend policies below.
103+
_DOCS_CSP = (
104+
"default-src 'self'; "
105+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
106+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; "
107+
"font-src https://fonts.gstatic.com; "
108+
"img-src 'self' data: https://fastapi.tiangolo.com; "
109+
"connect-src 'self'; "
110+
"base-uri 'self'; "
111+
"object-src 'none'"
112+
)
113+
114+
# JSON responses need nothing at all.
115+
_API_CSP = "default-src 'none'; base-uri 'none'"
116+
117+
# Mirrors frontend/_headers — see the comment there for why each source
118+
# is listed (Google Fonts, and a hash for index.html's inline JSON-LD
119+
# block). Duplicated rather than shared because the Worker and this app
120+
# are separate runtimes with no shared config to read from.
121+
_FRONTEND_CSP = (
122+
"default-src 'self'; "
123+
"script-src 'self' 'sha256-pDG7ywLQCTavmocE0AIF4eN7Dq/Ibx1SKkzQ6wMOiBg='; "
124+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
125+
"font-src https://fonts.gstatic.com; "
126+
"img-src 'self' data:; "
127+
"connect-src 'self'; "
128+
"base-uri 'self'; "
129+
"form-action 'self'; "
130+
"object-src 'none'"
131+
)
132+
133+
134+
def _csp_for_path(path: str) -> str:
135+
if path in _DOCS_PATHS:
136+
return _DOCS_CSP
137+
if path in _API_EXACT_PATHS or path.startswith(_API_PREFIX):
138+
return _API_CSP
139+
return _FRONTEND_CSP
140+
141+
142+
@app.middleware("http")
143+
async def add_security_headers(request, call_next):
144+
response = await call_next(request)
145+
response.headers["X-Content-Type-Options"] = "nosniff"
146+
# DENY instead of a CSP `frame-ancestors` directive — they express
147+
# the same "never frame this" rule and the task is to pick one, not
148+
# maintain both in lockstep.
149+
response.headers["X-Frame-Options"] = "DENY"
150+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
151+
response.headers["Content-Security-Policy"] = _csp_for_path(request.url.path)
152+
# HSTS is only safe once the app is actually reachable over HTTPS.
153+
# SESSION_COOKIE_SECURE=false is the existing flag for "this is
154+
# plain-http local dev" (see app/config.py) — reused here rather
155+
# than adding a second flag, so local dev never gets an HSTS header
156+
# a plain-http server couldn't honour anyway.
157+
if settings.SESSION_COOKIE_SECURE:
158+
response.headers["Strict-Transport-Security"] = (
159+
"max-age=63072000; includeSubDomains; preload"
160+
)
161+
return response
162+
71163
app.include_router(opportunities.router, prefix="/api/v1")
72164
app.include_router(scraper.router, prefix="/api/v1")
73165
app.include_router(subscribers.router, prefix="/api/v1")

docs/DEPLOY-CLOUDFLARE-WORKERS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,35 @@ point of view it's still one origin — **zero changes to cookies,
9191
the moderation queue loads (this is the real test of the cookie/CORS
9292
behavior above)
9393

94+
## HTTP security headers
95+
96+
A 2026-08 audit found no security headers (CSP, `X-Frame-Options`,
97+
`X-Content-Type-Options`, etc.) being sent anywhere. Two separate places
98+
needed them, since the two halves of this deployment never share a runtime:
99+
100+
- **The API/docs, proxied to Render**`backend/app/main.py` has an ASGI
101+
middleware (`add_security_headers`) that sets them on every response, with
102+
a path-scoped `Content-Security-Policy`: strict for JSON endpoints, a
103+
looser CDN/inline-allowing one for `/docs` and `/redoc` (FastAPI's stock
104+
Swagger UI / ReDoc pages), and one matching the frontend's below for the
105+
local-dev fallback where this same app serves `frontend/` directly.
106+
- **The static site, edge-served**`frontend/_headers` (Cloudflare's
107+
native mechanism for this — see
108+
[Headers](https://developers.cloudflare.com/workers/static-assets/headers/)
109+
confirmed via the Cloudflare docs MCP tool rather than assumed, since it
110+
supersedes the old Pages-only convention; no `run_worker_first` needed
111+
since nothing here has to run per-request). Verified against a real
112+
`wrangler dev` locally, including a gotcha the docs don't call out: hitting
113+
`/admin.html` 307-redirects to the extensionless `/admin`, so the CSP rule
114+
has to target `/admin` too or the page that actually gets served carries
115+
no policy at all.
116+
117+
`Strict-Transport-Security` is unconditional in `_headers` (Cloudflare only
118+
serves this site over HTTPS) but conditional in the backend middleware on
119+
`settings.SESSION_COOKIE_SECURE` — the existing flag for "this is plain-http
120+
local dev" — so local development never gets an HSTS header a plain-http
121+
server can't honour.
122+
94123
## What does *not* change
95124

96125
- The Render service keeps running exactly as-is — same Docker image, same

frontend/_headers

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# HTTP security headers for the static site (2026-08 audit: none were set
2+
# anywhere). Parsed by Cloudflare Workers static assets at the edge — see
3+
# https://developers.cloudflare.com/workers/static-assets/headers/ — and
4+
# applied to every file in this directory without the Worker script (worker/
5+
# index.js) ever running, matching how it already documents asset serving.
6+
#
7+
# Two things to know about this file's matching rules before editing it:
8+
# - When more than one block matches a URL, ALL of them apply; if the same
9+
# header name appears in more than one matching block, Cloudflare joins
10+
# the values with a comma instead of picking one. That's harmless for
11+
# most headers but would silently produce an invalid, unenforceable
12+
# Content-Security-Policy (commas aren't a valid directive separator —
13+
# only `;` is), so CSP is deliberately set in exactly one block per URL:
14+
# the page-specific ones below, never the `/*` baseline.
15+
# - Rules match the request path, not the resolved file, so both `/` and
16+
# `/index.html` need their own block even though they serve the same file
17+
# — and admin.html needs THREE: Cloudflare's default html_handling
18+
# redirects both `/admin` and `/admin.html` requests to a canonical URL,
19+
# but which one is canonical differs by request — checked against a
20+
# local `wrangler dev`: `/admin.html` -> 307 -> `/admin`, so `/admin` (no
21+
# extension) is what a browser actually lands on and needs the policy,
22+
# not just `/admin.html`.
23+
24+
# Baseline for every response: css/js/robots.txt/sitemap.xml/config.js, and
25+
# every page below inherits these too since no page block repeats them.
26+
/*
27+
X-Content-Type-Options: nosniff
28+
X-Frame-Options: DENY
29+
Referrer-Policy: strict-origin-when-cross-origin
30+
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
31+
32+
# index.html — same policy at both paths Cloudflare will actually receive
33+
# requests for. Only loads Google Fonts (fonts.googleapis.com/fonts.gstatic.com)
34+
# and one inline script: the `application/ld+json` SEO structured-data block.
35+
# JSON-LD can't be a separate file (crawlers expect it inline) and this static
36+
# file has no per-request templating to hang a nonce on, so it's allowlisted
37+
# by exact content hash instead of 'unsafe-inline' — the narrowest option that
38+
# still works for a fully static page. If that <script> block's content ever
39+
# changes, recompute the hash or the browser will silently drop the block:
40+
# python3 -c "import hashlib,base64,re; h=open('index.html',encoding='utf-8').read(); m=re.search(r'<script type=\"application/ld\+json\">\n(.*?)</script>', h, re.S); print('sha256-' + base64.b64encode(hashlib.sha256(m.group(1).encode()).digest()).decode())"
41+
# style-src needs 'unsafe-inline' for the many style="..." attributes on
42+
# individual elements (checked: no <style> block or .cssText/style-string
43+
# writes in app.js, just per-property assignments, which CSP style-src
44+
# doesn't govern anyway — but the attributes do).
45+
/
46+
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-pDG7ywLQCTavmocE0AIF4eN7Dq/Ibx1SKkzQ6wMOiBg='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'
47+
48+
/index.html
49+
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-pDG7ywLQCTavmocE0AIF4eN7Dq/Ibx1SKkzQ6wMOiBg='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'
50+
51+
# admin.html — same needs minus the JSON-LD block (it has none: config.js and
52+
# js/admin.js are both external <script src>, no inline script anywhere), so
53+
# script-src stays 'self' with no hash/unsafe-inline needed at all.
54+
/admin.html
55+
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'
56+
57+
/admin
58+
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'

0 commit comments

Comments
 (0)