Skip to content

Commit 78e37c9

Browse files
authored
Merge pull request #31 from adab-tech/security/http-headers-clean
Add HTTP security headers to API responses and static assets
2 parents cfd71fb + 141f688 commit 78e37c9

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)