⚠️ 自动镜像 · 此页由
docs-site/scripts/mirror-adr.mjs从docs/adr/0010-security-headers-middleware.md生成,请勿直接编辑此处;改源文件后pnpm docs:build会自动同步。
ADR-0010: Production Security Headers Middleware
- Status: Accepted (v0.10.12 update — script-src / style-src nonce 收紧已落地)
- Date: 2026-05-07 (v0.9.11 update: 2026-05-09; v0.10.12 update: 2026-05-18)
- Supersedes: —
- Related: deploy.md(nginx TLS 终结), infra/docker/nginx.conf
Context
v0.8.0 deploy.md 已经写明 production 用 nginx 端做 TLS 终结,但 FastAPI 本身没有任何安全响应头:浏览器吃到的 production 响应缺 HSTS / CSP / X-Frame-Options 等保护——典型攻击面:
- 协议降级:用户首次访问
http://...后被中间人劫持到 https→http。 - iframe 钓鱼:站点可被任意页面嵌入 iframe。
- XSS:v0.8.7 起前端引入 Cloudflare Turnstile 第三方脚本,缺乏 CSP 会让任意注入脚本无差别执行。
deploy.md 给的 nginx 例子里可以加 add_header,但这把责任留给 ops,应用本身没保障(dev 用 uvicorn 直跑、staging 不走 nginx 时彻底 裸奔)。把头部下沉到 FastAPI middleware 才是 single source of truth。
Decision
新增 production-only middleware app/middleware/security_headers.py:
| Header | Value | 理由 |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains | 1 年 + 子域,preload 留待运维评估后追加。 |
X-Content-Type-Options | nosniff | 禁止浏览器 MIME sniff,防 XSS via type confusion。 |
X-Frame-Options | DENY | 旧浏览器 fallback。 |
Referrer-Policy | strict-origin-when-cross-origin | 跨站只送 origin、同站完整 referrer。 |
Content-Security-Policy | 见下文 | 限制脚本 / 样式 / 资源源。 |
注册:environment == "production" 才注册;dev / staging 跳过, 避免本地热更新被 inline script 打挂、docs-site 被 frame 限制。
注册顺序:在 CORSMiddleware 之前 add_middleware(FastAPI 后注册先执行→ SecurityHeaders 是栈底,dispatch 后写入响应时是最外层)→ CORS 头与 SecurityHeaders 头能并存。
CSP 基线版本
default-src 'self';
img-src 'self' data: blob: https:;
style-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com;
frame-src https://challenges.cloudflare.com;
connect-src 'self' https: wss: ws:;
font-src 'self' data:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none'当前折衷:
style-src 'unsafe-inline':前端运行时仍有部分 inline style(emotion 等 CSS-in-JS、第三方库)。strict 之前需要先排查全量 inline。script-src 'unsafe-inline':Vite-built bundle 中部分 polyfill / HMR shim 使用 inline,先 baseline 通过;下一阶段切到 nonce-based。https://challenges.cloudflare.com:Turnstile widget(CAPTCHA)的 script + frame-src,硬编码而非通配。connect-src包括wss:ws::notification socket 与未来 ML backend 直连保留弹性。
v0.9.11 / v0.10.12 已分别收紧 script-src 与 style-src,上述基线保留为 初始决策记录;当前生产策略见下方 update 段。
/metrics 由独立 ASGI 子应用挂载(main.py),不经过本中间件——这是 有意设计:Prometheus 内网 scrape 不需要 HSTS / CSP。
Consequences
正向:
- production 响应自带防护,运维 nginx 配置出错也有兜底。
- 单一来源更新(改 middleware 即覆盖所有 service),不需要在 deploy.md 里维护一份 nginx snippet 副本。
- dev / staging 完全无影响(
if settings.environment == "production"包裹)。
负向 / 风险:
- CSP 当前为「宽松基线」,不等于 strict 防御;XSS 攻击面只缩窄到「不允许 跨域加载脚本」。下一阶段需要 nonce-based 收紧。
- 若新增第三方依赖(如 Sentry CDN、Google Fonts、第三方 ML backend iframe)需要同步更新 CSP,遗忘会导致 ResourceBlocked 报错。建议
docs-site/dev/security.md加 checklist。 - HSTS
max-age=31536000在 mistake config 时锁死浏览器 1 年——上线 前应当先用max-age=300灰度 24h,确认 https 稳定后再切换长 TTL。 本 ADR 默认值适合稳定 production;初次切换的运维 SOP 留给 deploy.md。
v0.9.11 Update — script-src nonce 收紧
已落地(2026-05-09):
- HTML 出站路径:
infra/docker/nginx.conf启用sub_filter把 vite plugin (apps/web/vite-plugins/csp-nonce.ts) 注入的__CSP_NONCE__占位符替换为$request_id(Nginx 自动生成的 32-char hex)。同请求 CSP header 也用同一个$request_id,HTML 内<script nonce="...">与 header 中script-src'nonce-XXX'完全一致。 - API 响应路径:
SecurityHeadersMiddleware的 CSP 直接收紧script-src'self' https://challenges.cloudflare.com`(无 nonce — API 响应不含 HTML,没有 inline script 的合法用例)。'unsafe-inline'` 完全移除。 - Turnstile 兼容:
apps/web/src/lib/turnstile.ts动态注入 script 时读<meta name="csp-nonce">设script.nonce。Cloudflare 域已在script-src白名单里,nonce + 域名双因子允许。 - 当时的 style-src 取舍:v0.9.11 仍保留
style-src 'unsafe-inline', 前端 ~2600 处<style={{}}>全量重构留到 v0.10.x 同窗口做。
为什么走 Nginx 而不是 FastAPI:SPA 由 Nginx 直接 serve /usr/share/nginx/html, FastAPI 不出 HTML。改 Nginx 比把 SPA 移到 FastAPI route + Jinja2 模板小一个数量级 (不用动 vite build 输出 / 静态资源路由)。代价是 nginx.conf 与 middleware 双源 CSP 策略需要保持同步——靠注释交叉引用 + 集成测试 (test_security_headers.py 验 API 路径)。
v0.10.12 Update — style-src nonce 收紧
已落地(2026-05-18):
- 前端前置:
apps/web/src/**/*.tsx的 JSXstyle=/<style>已清零,apps/web/eslint.config.js的no-restricted-syntaxguard 已覆盖全站 TSX。 - HTML 出站路径:
infra/docker/nginx.conf的 CSP 改为style-src 'self' 'nonce-$request_id'。apps/web/vite-plugins/csp-nonce.ts除<script>外也给 build 后 index.html 中的<style>标签补nonce="__CSP_NONCE__",继续由 Nginxsub_filter替换为$request_id。 - API 响应路径:
SecurityHeadersMiddleware的 CSP 改为style-src 'self'。 API 响应不含 HTML,无合法 inline style 用例,也不需要 nonce。 - 测试约束:
apps/api/tests/test_security_headers.py断言 API CSP 全文不含'unsafe-inline',避免 script/style 任一 directive 回潮。
Follow-ups
Permissions-Policy头补全(camera / microphone 等)。- CORS preflight 路径是否需要单独 short-circuit 跳过 SecurityHeaders? 当前不跳过——浏览器看 OPTIONS 响应也希望带 HSTS。