@@ -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+
71163app .include_router (opportunities .router , prefix = "/api/v1" )
72164app .include_router (scraper .router , prefix = "/api/v1" )
73165app .include_router (subscribers .router , prefix = "/api/v1" )
0 commit comments