FastAPI 怎么配 CORS 和安全响应头?前后端分离要注意什么?
简化版
CORS 是浏览器的同源策略机制,配置它是「放宽限制」而不是「增加安全」——FastAPI 用 CORSMiddleware 配置:allow_origins(必须写具体域名,不要用 *)、allow_credentials(带 Cookie 时开,开了之后 allow_origins 绝不能是 *,浏览器会直接拒绝)、allow_methods、allow_headers、以及 expose_headers(不写的话前端读不到自定义响应头,比如 X-Total-Count)。JSON API 基本都会触发预检(因为 Content-Type: application/json 不属于「简单请求」的三种类型),所以要用 max_age 缓存预检结果减少往返。必须分清 CORS 和 CSRF:CORS 管的是「别的站点能不能读你的响应」,CSRF 防的是「别的站点能不能替用户发请求」——配 CORS 防不住 CSRF,因为攻击者的表单提交是简单请求、根本不需要读响应。判断要不要 CSRF 防护的标准是「凭证是不是浏览器自动携带的」:用 Authorization: Bearer 头的纯 Token API 天然免疫(浏览器不会自动加这个头),用 Cookie 就必须防(SameSite + CSRF Token)。安全响应头要靠自己加中间件:X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Referrer-Policy、Strict-Transport-Security、以及 CSP(如果有前端页面)。FastAPI 还有两个特有的项:生产环境要关掉 /docs 和 /openapi.json(或加鉴权),以及 TrustedHostMiddleware 防 Host 头攻击。核心记忆:CORS 是放宽不是加固;credentials 时 origins 不能是 *;expose_headers 前端才读得到;CORS 防不住 CSRF;生产关文档。
详细版
CORS 配置项速查:
| 参数 | 作用 | 注意 |
|---|---|---|
allow_origins | 允许的来源列表 | 别用 * |
allow_origin_regex | 正则匹配来源 | 适合多子域 |
allow_credentials | 允许带 Cookie | 开了 origins 不能是 * |
allow_methods | 允许的方法 | 默认只有 GET |
allow_headers | 允许的请求头 | 自定义头要列出 |
expose_headers | 前端可读的响应头 | 不写就读不到 |
max_age | 预检缓存秒数 | 减少 OPTIONS 往返 |
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.middleware.gzip import GZipMiddleware
app = FastAPI(
docs_url="/docs" if settings.DEBUG else None, # ★★生产关文档★★
redoc_url=None,
openapi_url="/openapi.json" if settings.DEBUG else None,
)
# ① ★★CORS(中间件顺序:后 add 的先执行)★★
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS, # ★["https://app.example.com"]★
# allow_origin_regex=r"https://.*\.example\.com", # ★多子域时用★
allow_credentials=True, # ★★带 Cookie 时★★
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-Request-Id"],
expose_headers=["X-Total-Count", "X-Request-Id"], # ★★前端才读得到★★
max_age=600, # ★预检缓存 10 分钟★
)
# ✗ allow_origins=["*"] + allow_credentials=True → ★★浏览器直接拒绝★★
# ② ★Host 头校验(防 Host 头攻击 / 缓存投毒)★
app.add_middleware(TrustedHostMiddleware,
allowed_hosts=["api.example.com", "*.example.com"])
# ③ ★★安全响应头(FastAPI 没有内置,自己写)★★
class SecurityHeadersMiddleware: # ★纯 ASGI,不破坏流式★
def __init__(self, app): self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
async def send_wrapper(message):
if message["type"] == "http.response.start":
headers = message.setdefault("headers", [])
headers.extend([
(b"x-content-type-options", b"nosniff"),
(b"x-frame-options", b"DENY"),
(b"referrer-policy", b"strict-origin-when-cross-origin"),
(b"permissions-policy", b"geolocation=(), microphone=()"),
(b"strict-transport-security",
b"max-age=31536000; includeSubDomains"),
])
await send(message)
await self.app(scope, receive, send_wrapper)
app.add_middleware(SecurityHeadersMiddleware)
# ④ ★Cookie 认证时的加固★
response.set_cookie(
"session", token,
httponly=True, # ★JS 读不到(防 XSS 偷 token)★
secure=True, # ★★只走 HTTPS★★
samesite="lax", # ★★挡住大部分 CSRF★★
max_age=3600,
path="/",
)
# ★跨站带 Cookie(前后端不同域)时必须:★
# samesite="none" + secure=True ★★两者缺一不可★★
# ⑤ ★HTTPS 强制(如果没有反代处理)★
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
app.add_middleware(HTTPSRedirectMiddleware)
# ⑥ ★反代后拿真实 IP 和协议★
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
# 或 uvicorn --proxy-headers --forwarded-allow-ips="10.0.0.0/8"
# ★★不配的话 request.client.host 是代理 IP、request.url 是 http★★
# ⑦ ★文档加鉴权(不想完全关闭时)★
from fastapi.openapi.docs import get_swagger_ui_html
@app.get("/docs", include_in_schema=False)
async def docs(user: AdminUser): # ★★依赖里做鉴权★★
return get_swagger_ui_html(openapi_url="/openapi.json", title="API")
⚠️ 三个必须记住的点:①
allow_credentials=True时allow_origins绝对不能是["*"]。这是 CORS 规范的硬性规定——浏览器会直接拒绝这样的响应(报「The value of the ‘Access-Control-Allow-Origin’ header must not be the wildcard ’*’ when the request’s credentials mode is ‘include’」)。Starlette 的CORSMiddleware在这种组合下会把Access-Control-Allow-Origin设为具体的请求 Origin 而不是*,但如果你自己手写中间件回显 Origin,就等于对所有来源开放了——必须先做白名单校验,并且加上Vary: Origin响应头(否则 CDN/代理会把针对 A 站点的响应缓存后发给 B 站点)。②expose_headers不配的话,前端 JS 读不到自定义响应头。浏览器默认只暴露七个「简单响应头」(Cache-Control、Content-Language、Content-Length、Content-Type、Expires、Last-Modified、Pragma)——你返回的X-Total-Count(分页总数)、X-Request-Id(排障用)、Location(201 创建后的资源地址)在跨域场景下前端全都拿不到,而且不会报错、只是resp.headers.get(...)返回null,排查起来很困惑。③ CORS 和 CSRF 是两件事,配了 CORS 防不住 CSRF。攻击者用<form action="https://api.example.com/transfer" method="post">提交时,浏览器会自动带上你的 Cookie,请求正常执行——CORS 只拦住了「攻击者的 JS 读取响应内容」,但转账已经完成了。所以:用 Cookie 认证就必须做 CSRF 防护(SameSite=Lax+ CSRF Token);用Authorization: Bearer头认证则天然免疫(浏览器不会自动附加这个头,必须由 JS 显式设置,而攻击者的页面读不到你的 token)。
完整版教学
一、CORS 的机制
★ ★同源策略限制的是什么★:
同源 = ★协议 + 域名 + 端口 全部相同★
✗ ★JS 读取跨源响应的内容★
✗ 读取跨源的 DOM / Cookie / localStorage
✓ ★★但请求本身可以发出去!★★
<img src="跨源">、<form action="跨源">、<script src="跨源">
★ ★这正是 CSRF 能成立的原因★
★ ★简单请求 vs 预检请求★:
★简单请求(不预检)★需同时满足:
① 方法是 ★GET / HEAD / POST★
② Content-Type 是 ★text/plain / multipart/form-data /
application/x-www-form-urlencoded★ 之一
③ ★没有自定义请求头★
★需要预检(先发 OPTIONS)★:
✗ ★方法是 PUT / PATCH / DELETE★
✗ ★Content-Type: application/json★ ← ★★JSON API 全中★★
✗ ★有 Authorization / X-Request-Id 等自定义头★
★ ★预检的完整流程★:
┌────────────────────────────────────────────────────────┐
│ ① 浏览器先发 ★OPTIONS★: │
│ Origin: https://app.example.com │
│ Access-Control-Request-Method: POST │
│ Access-Control-Request-Headers: content-type,authorization│
│ ② 服务端回: │
│ Access-Control-Allow-Origin: https://app.example.com │
│ Access-Control-Allow-Methods: POST │
│ Access-Control-Allow-Headers: content-type,authorization│
│ Access-Control-Allow-Credentials: true │
│ ★Access-Control-Max-Age: 600★ ← ★缓存,减少往返★ │
│ ③ ★通过后才发真实请求★ │
└────────────────────────────────────────────────────────┘
★ ★预检会让请求数翻倍★ → ★max_age 很有价值★
(Chrome 上限 2 小时、Firefox 24 小时)
★ ★响应头的含义★:
Access-Control-Allow-Origin # ★谁能读响应★
Access-Control-Allow-Credentials # ★能不能带 Cookie★
Access-Control-Expose-Headers # ★★前端能读哪些响应头★★
Access-Control-Allow-Methods # ★预检时告知★
Access-Control-Allow-Headers # ★预检时告知★
Access-Control-Max-Age # 预检缓存
★ ★★CORS 不是安全机制(最重要的认知)★★:
★ 它保护的是 ★"用户的数据不被恶意站点的 JS 读走"★
★ 它★不保护你的服务端★:
- ★curl / Postman / 服务端请求完全不受 CORS 约束★
- ★配了 CORS 白名单 ≠ 只有白名单能调你的 API★
→ ★★真正的访问控制靠认证和鉴权,不是 CORS★★
★ ★常见的配置错误★:
✗ CORSMiddleware(allow_origins=["*"], allow_credentials=True)
→ ★浏览器拒绝★
✗ ★自己写中间件无脑回显 Origin★:
resp.headers["Access-Control-Allow-Origin"] = request.headers["Origin"]
→ ★★等于对所有来源开放★★
✓ 白名单校验后再回显 + ★Vary: Origin★
✗ allow_origins=["https://example.com/"] # ★★结尾的斜杠!★★
→ ★Origin 头永远不带路径和结尾斜杠 → 匹配失败★
✗ 忘了 allow_methods(★默认只有 GET★)
理解 CORS 要先理解同源策略限制的是「JS 读取跨源响应」而不是「发出请求」——请求照样能发出去,这正是 CSRF 能成立的原因。JSON API 基本都会触发预检(因为 application/x-www-form-urlencoded 之外的 Content-Type 不属于简单请求),预检会让请求数翻倍,所以 max_age 很有价值。最重要的认知是「CORS 不是安全机制」——它保护的是「用户的数据不被恶意站点的 JS 读走」,完全不保护你的服务端:curl、Postman、服务端请求都不受 CORS 约束,配了白名单不等于只有白名单能调你的 API,真正的访问控制靠认证鉴权。常见配置错误里有个很隐蔽的:allow_origins 里写了结尾的斜杠("https://example.com/")——Origin 头永远不带路径和结尾斜杠,所以匹配不上。
二、CORS vs CSRF
★ ★两者的定位★:
┌──────────────┬────────────────────┬──────────────────────┐
│ │ ★CORS★ │ ★CSRF★ │
├──────────────┼────────────────────┼──────────────────────┤
│ 是什么 │ ★浏览器机制★ │ ★一种攻击★ │
│ 解决 │ ★能不能"读"响应★ │ ★能不能"替你发"请求★ │
│ 配置的效果 │ ★放宽限制★ │ ★增加防护★ │
│ 防护位置 │ 浏览器执行 │ ★服务端校验★ │
└──────────────┴────────────────────┴──────────────────────┘
★ ★★为什么 CORS 防不住 CSRF★★:
攻击者页面:
<form action="https://api.example.com/transfer" method="POST">
<input name="to" value="attacker"><input name="amount" value="10000">
</form>
<script>document.forms[0].submit()</script>
→ ★浏览器自动带上 api.example.com 的 Cookie★
→ ★这是"简单请求"(POST + urlencoded),★不触发预检★★
→ ★★服务端正常执行了转账★★
→ 攻击者的 JS 读不到响应(CORS 起作用了)
→ ★★但钱已经转走了★★
★ ★★判断要不要 CSRF 防护的唯一标准★★:
★"凭证是不是浏览器自动携带的?"★
┌────────────────────────────┬──────────────────────┐
│ ★Cookie / Session★ │ ★★必须防 CSRF★★ │
│ ★Authorization: Bearer★ │ ★★天然免疫★★ │
│ ★自定义头(X-Token)★ │ ★天然免疫★ │
│ ★把 token 存在 Cookie 里★ │ ★★又需要防了★★ │
└────────────────────────────┴──────────────────────┘
★ 原因:★Authorization 头必须由 JS 显式设置★
→ ★攻击者的页面读不到你的 token(同源策略保护 localStorage)★
→ ★也无法让浏览器自动附加★
★ ★如果用 Cookie 认证,怎么防★:
① ★★SameSite Cookie(现代主力)★★
samesite="lax" # ★顶级导航的 GET 带、其他跨站请求不带★
samesite="strict" # ★任何跨站都不带(从外链进来也是未登录)★
samesite="none" # ★都带,★必须配 secure=True★★
★ 现代浏览器 ★默认就是 lax★ → 大部分 CSRF 已被自动挡住
★ ✗ 但:★前后端不同域时必须用 none★,★这时 SameSite 就不防了★
② ★双提交 Cookie(Double Submit)★
后端下发一个 csrf_token cookie(★非 HttpOnly,前端能读★)
前端每次请求把它放进 ★X-CSRF-Token★ 请求头
后端校验 ★cookie 里的值 == 请求头里的值★
★ 原理:★攻击者的站点读不到你的 cookie★(同源策略)
★ FastAPI 实现:
@app.middleware("http") # ★或纯 ASGI★
async def csrf(request, call_next):
if request.method in ("POST","PUT","PATCH","DELETE"):
c = request.cookies.get("csrf_token")
h = request.headers.get("x-csrf-token")
if not c or c != h:
return JSONResponse({"detail":"CSRF 校验失败"}, 403)
return await call_next(request)
③ ★校验 Origin / Referer(补充手段)★
origin = request.headers.get("origin") or request.headers.get("referer")
if origin and urlparse(origin).netloc not in ALLOWED_HOSTS:
raise HTTPException(403)
★ Referer 可能被隐私设置去掉 → ★不能单独依赖★
★ ★JWT 存哪里的经典权衡★:
┌──────────────┬──────────────────────────────────────┐
│ ★localStorage★│ ✓ ★免疫 CSRF★ │
│ │ ✗ ★XSS 能偷走★ │
│ ★HttpOnly │ ✓ ★XSS 偷不走★ │
│ Cookie★ │ ✗ ★需要 CSRF 防护★ │
└──────────────┴──────────────────────────────────────┘
★ ★没有完美答案★:
- ★有 XSS 的话两种都完蛋★(XSS 能直接发请求,不需要偷 token)
- ★所以首要任务是防 XSS★
★ 现实选择:★HttpOnly Cookie + SameSite + CSRF Token★(纵深防御)
或 ★短期 access token 存内存 + refresh token 存 HttpOnly Cookie★
CORS 和 CSRF 是方向相反的两件事:CORS 管「能不能读响应」、CSRF 防「能不能替你发请求」。攻击者的表单提交是简单请求、不触发预检、服务端正常执行——CORS 只拦住了「读响应」,但钱已经转走了。判断要不要 CSRF 防护的唯一标准是「凭证是不是浏览器自动携带的」:Cookie 必须防、Authorization: Bearer 天然免疫(因为那个头必须由 JS 显式设置,而攻击者读不到你的 token)。用 Cookie 时的防护手段:SameSite=Lax(现代主力,浏览器默认)、双提交 Cookie(原理是攻击者站点读不到你的 cookie)、Origin/Referer 校验(补充)。有个重要提醒:前后端不同域时必须用 SameSite=None,这时 SameSite 就不防 CSRF 了,必须靠 Token。最后是 JWT 存哪里的经典权衡——localStorage 免疫 CSRF 但怕 XSS,HttpOnly Cookie 防 XSS 但需要 CSRF 防护,没有完美答案;而且有 XSS 的话两种都完蛋(攻击者可以直接用你的身份发请求),所以首要任务是防 XSS。
三、安全响应头
★ ★FastAPI 没有内置安全头,要自己加★:
┌──────────────────────────────┬──────────────────────────┐
│ ★X-Content-Type-Options★ │ nosniff │
│ │ ★禁止 MIME 嗅探★ │
│ ★X-Frame-Options★ │ DENY / SAMEORIGIN │
│ │ ★防点击劫持★ │
│ ★Strict-Transport-Security★ │ max-age=31536000; │
│ │ includeSubDomains │
│ │ ★强制 HTTPS★ │
│ ★Referrer-Policy★ │ strict-origin-when- │
│ │ cross-origin │
│ │ ★防 URL 泄露到第三方★ │
│ ★Content-Security-Policy★ │ ★XSS 的最后防线★ │
│ ★Permissions-Policy★ │ 限制浏览器 API │
└──────────────────────────────┴──────────────────────────┘
★ ★纯 API 服务需要哪些★:
✓ ★nosniff★(防止 JSON 被当成 HTML 解析执行)
✓ ★HSTS★(如果直接对外)
✓ ★Referrer-Policy★
✗ ★CSP 对纯 API 意义不大★(没有页面)
→ 但如果返回的内容可能被直接在浏览器打开(文件下载),
★加 CSP: sandbox 或 Content-Disposition: attachment★
✗ X-Frame-Options 对 API 也没意义(不会被 iframe)
★ ★有前端页面(模板渲染/静态文件)时全都要★
★ ★★为什么 nosniff 对 JSON API 重要★★:
某些老浏览器会★嗅探内容★决定如何解析
→ 如果你的 API 返回了用户可控的内容且 Content-Type 被绕过
→ ★可能被当成 HTML 执行 → XSS★
✓ nosniff 强制浏览器★严格按 Content-Type 处理★
★ ★CSP 的渐进式落地(有页面时)★:
# ① 先用 Report-Only 模式收集违规
Content-Security-Policy-Report-Only:
default-src 'self'; report-uri /csp-report
# ② 根据报告调整,把内联脚本外置或加 nonce
# ③ 确认无误后切成强制模式
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{random}'; # ★★避免 unsafe-inline★★
object-src 'none';
frame-ancestors 'none'; # ★比 X-Frame-Options 更现代★
base-uri 'self';
★ ★加了 unsafe-inline 的 CSP 防 XSS 效果基本归零★
★ ★中间件写法的选择★:
# ① @app.middleware("http")(BaseHTTPMiddleware)
★ ✓ 简单
★ ✗ ★会破坏 StreamingResponse 的流式★
★ ✗ 性能开销大
# ② ★纯 ASGI 中间件(★推荐★)★
★ ✓ ★不缓冲响应体、性能好★
★ ✓ 能精确控制 http.response.start 消息
★ ★中间件的执行顺序(★容易搞错★)★:
app.add_middleware(A)
app.add_middleware(B)
app.add_middleware(C)
→ ★★请求进入顺序:C → B → A → 路由★★
→ ★响应返回顺序:路由 → A → B → C★
★ ★后 add 的先执行(像栈)★
★ 实践:
- ★CORS 应该在最外层★(最后 add)→ ★这样异常响应也带 CORS 头★
- ★不然 500 错误的响应没有 CORS 头,前端只能看到"CORS error"
而看不到真正的错误★
★ ★TrustedHostMiddleware(防 Host 头攻击)★:
app.add_middleware(TrustedHostMiddleware,
allowed_hosts=["api.example.com", "*.example.com"])
★ 防的是:
① ★Host 头投毒★(生成的绝对 URL 指向攻击者域名 → 密码重置链接被劫持)
② ★缓存投毒★
★ ✗ 注意:★健康检查用 IP 访问会被拒★ → 加 "localhost", "127.0.0.1"
FastAPI 没有内置安全头,要自己写中间件。纯 API 服务需要的是 nosniff(防 JSON 被当成 HTML 执行)、HSTS、Referrer-Policy;CSP 和 X-Frame-Options 对纯 API 意义不大(没有页面、不会被 iframe),但有前端页面时全都要。中间件的执行顺序容易搞错:后 add 的先执行(像栈)——CORS 应该在最外层(最后 add),这样异常响应也会带上 CORS 头,否则 500 错误时前端只能看到「CORS error」而看不到真正的错误信息,排查会非常困难。TrustedHostMiddleware 防的是 Host 头投毒(攻击者伪造 Host 让密码重置链接指向自己的域名)——注意用 IP 做健康检查会被拒,要把 localhost/127.0.0.1 加进白名单。
四、前后端分离的完整配置
★ ★场景一:同域部署(★最省事★)★
https://example.com/ → 前端静态文件
https://example.com/api/ → FastAPI
★ ✓ ★根本不需要 CORS★(同源)
★ ✓ Cookie 可以用 SameSite=Lax
★ ✓ 没有预检开销
★ ★能同域就同域★(Nginx 按路径分流)
★ ★场景二:不同子域★
https://app.example.com → 前端
https://api.example.com → 后端
★ 配置:
CORS: allow_origins=["https://app.example.com"], credentials=True
Cookie: ★domain=".example.com"★ + samesite="lax" + secure=True
★ ✓ ★同站(same-site)所以 SameSite=Lax 仍然生效★
★ ★这是推荐的分离方案★
★ ★场景三:完全不同域(★最麻烦★)★
https://myapp.com → 前端
https://api.other.com → 后端
★ 配置:
CORS: allow_origins=["https://myapp.com"], credentials=True
Cookie: ★samesite="none" + secure=True★(缺一不可)
★ ✗ ★SameSite 不再提供 CSRF 防护★ → ★必须上 CSRF Token★
★ ✗ ★Safari 的 ITP 可能屏蔽第三方 Cookie★
✓ ★这种场景建议改用 Bearer Token★(避免 Cookie 的一堆问题)
★ ★"本地能用线上不行"的经典原因★:
┌────────────────────────────────────────────────────┐
│ ① ★本地 http://localhost 不在 allow_origins 里★ │
│ ② ★Cookie 的 secure=True 在 http 下不发送★ │
│ ③ ★SameSite=None 必须配 Secure★ │
│ ④ ★前端忘了 credentials: "include"★ │
│ ⑤ ★反代没转发 Origin 头★ │
│ ⑥ ★OPTIONS 请求被鉴权中间件拦了(返回 401)★ │
└────────────────────────────────────────────────────┘
★ ★⑥ 特别隐蔽★:预检请求不带认证信息,
如果你的鉴权中间件对所有请求都要求 token → ★预检就失败了★
✓ CORSMiddleware 放在鉴权之外(最后 add)
✓ 或鉴权时跳过 OPTIONS
★ ★前端的正确写法★:
fetch(url, {
method: "POST",
★credentials: "include"★, # ★★带 Cookie 必须★★
headers: {"Content-Type": "application/json",
"X-CSRF-Token": getCookie("csrf_token")},
body: JSON.stringify(data),
})
# axios
axios.defaults.withCredentials = true;
★ ★开发环境的配置★:
CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]
★ ★localhost 和 127.0.0.1 是不同的 Origin!★
✓ 或者用 Vite/Webpack 的 ★proxy★(★开发时变成同源,最省事★)
// vite.config.js
server: { proxy: { "/api": "http://localhost:8000" } }
★ ★调试 CORS 的顺序★:
① ★看浏览器控制台的具体错误★(缺哪个头写得很清楚)
② ★看 Network 里的 OPTIONS 请求★(状态码?返回了什么头?)
③ ★curl 模拟★:
curl -i -X OPTIONS https://api.x.com/items \
-H "Origin: https://app.x.com" \
-H "Access-Control-Request-Method: POST"
④ ★检查 allow_origins 里有没有多余的斜杠★
⑤ ★确认 credentials 和 origins=* 没冲突★
★ ★注意:CORS 是浏览器行为,curl 直接请求永远"成功"★
前后端分离的三种场景难度递增:同域部署根本不需要 CORS(Nginx 按路径分流,能同域就同域);不同子域是推荐方案(Cookie 用 domain=".example.com",同站所以 SameSite=Lax 仍然生效);完全不同域最麻烦(必须 SameSite=None + Secure,此时 SameSite 不再防 CSRF,还要面对 Safari ITP 屏蔽第三方 Cookie——这种场景建议改用 Bearer Token)。「本地能用线上不行」的六个原因里,第六个特别隐蔽:预检请求不带认证信息,如果鉴权中间件对所有请求都要求 token,OPTIONS 就会返回 401 导致 CORS 失败——解法是把 CORSMiddleware 放在最外层或鉴权时跳过 OPTIONS。开发环境最省事的做法是用 Vite/Webpack 的 proxy 变成同源。调试时注意 CORS 是浏览器行为,curl 直接请求永远「成功」。
五、其他安全项
★ ★① 生产环境关闭文档★:
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
★ 为什么:
① ★暴露了全部接口、参数结构、数据模型★
② ★攻击者可以直接看到有哪些内部接口★
③ ★Swagger UI 的"Try it out"可以直接调用★
✓ ★或者加鉴权★:
@app.get("/docs", include_in_schema=False)
async def docs(user: AdminUser):
return get_swagger_ui_html(openapi_url="/openapi.json", ...)
★ ★注意 openapi_url 也要保护★(否则直接下载 schema)
★ ★② 错误信息不泄露★:
✗ 默认的 500 在 debug 下会返回堆栈
✓ 生产:
@app.exception_handler(Exception)
async def handle(request, exc):
logger.exception("unhandled", extra={"path": request.url.path})
return JSONResponse({"detail": "内部错误",
"request_id": get_request_id()}, 500)
★ ★别把 exc 的内容返回给用户★(可能含 SQL、路径、密钥)
★ ★③ 422 的响应可能泄露输入★:
Pydantic V2 的错误里有 ★input 字段★(回显用户提交的原始值)
→ ★如果用户在 password 字段传了错误类型,密码会出现在错误响应里★
✓ 自定义处理器过滤:
@app.exception_handler(RequestValidationError)
async def validation_handler(request, exc):
errors = [{"loc": e["loc"], "msg": e["msg"], "type": e["type"]}
for e in exc.errors()] # ★★不含 input 和 url★★
return JSONResponse({"detail": errors}, 422)
★ ★④ 限流★:
from slowapi import Limiter
limiter = Limiter(key_func=get_remote_address,
storage_uri="redis://...") # ★★必须共享存储★★
@app.post("/login")
@limiter.limit("5/minute") # ★登录接口重点限★
async def login(request: Request, ...): ...
★ ★key_func 用 IP 时要先配 ProxyHeaders★,否则所有人是同一个 IP
★ 429 响应要带 Retry-After
★ ★⑤ 依赖与镜像安全★:
pip-audit # ★扫描已知漏洞★
★ 锁版本(uv.lock / poetry.lock / requirements.txt)★
★ Docker:非 root 用户、多阶段构建、最小基础镜像★
★ 定期升级 FastAPI/Starlette/Pydantic(都出过安全公告)★
★ ★⑥ 认证相关的细节★:
□ ★密码用 bcrypt/argon2(★注意它慢,要在 def 路由或线程池★)★
□ ★JWT 锁死 algorithms=["HS256"](防 alg:none)★
□ ★access token 短(15 分钟)+ refresh token★
□ ★登录失败不区分"用户不存在"和"密码错误"★
□ ★登录接口限流★
□ ★JWT payload 只是 base64,不能放敏感信息★
★ ★⑦ 大小与超时限制★:
□ ★请求体大小(Nginx + 中间件)★
□ ★超时(gunicorn --timeout)★
□ ★分页 size 上限★
□ ★正则要防 ReDoS★(Pydantic 的 pattern 也是)
□ ★JSON 嵌套深度★(防解析炸弹)
其他安全项里,生产关闭 /docs 时别忘了 openapi_url 也要保护(否则能直接下载完整的 schema)。Pydantic V2 的 422 错误里有 input 字段会回显用户提交的原始值——如果用户在 password 字段传了错误类型,密码就会出现在错误响应里,要自定义处理器过滤掉。限流的 key_func 用 IP 时必须先配 ProxyHeaders,否则反代后所有请求都是同一个 IP。认证细节里两个 FastAPI 特有的:bcrypt 很慢,要放在 def 路由或线程池里(否则卡住事件循环)、JWT 的 algorithms 必须写死防 alg:none。
六、实践清单
★ 标准配置模板:
def create_app() -> FastAPI:
app = FastAPI(
docs_url="/docs" if settings.DEBUG else None,
openapi_url="/openapi.json" if settings.DEBUG else None,
)
# ★★注意顺序:后 add 的先执行,CORS 要在最外层★★
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(TrustedHostMiddleware,
allowed_hosts=settings.ALLOWED_HOSTS)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware( # ★★最后 add = 最外层★★
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"], allow_headers=["*"],
expose_headers=["X-Request-Id", "X-Total-Count"],
max_age=600,
)
return app
★ 检查清单:
【CORS】
□ ★allow_origins 是具体域名,不是 *★
□ ★credentials=True 时 origins 不是 *★
□ ★expose_headers 列出了前端要读的自定义头★
□ ★allow_origins 里没有多余的结尾斜杠★
□ ★CORS 中间件在最外层(最后 add)★
□ ★OPTIONS 不被鉴权拦截★
【CSRF】
□ ★用 Cookie 认证 → 有 CSRF 防护★
□ ★用 Bearer Token → 确认 token 不在 Cookie 里★
□ ★Cookie 配了 HttpOnly + Secure + SameSite★
【安全头】
□ ★nosniff★
□ ★HSTS(对外服务)★
□ ★Referrer-Policy★
□ ★有页面时加 CSP 和 X-Frame-Options★
【其他】
□ ★生产关闭 /docs 和 /openapi.json★
□ ★TrustedHostMiddleware★
□ ★错误响应不泄露堆栈★
□ ★422 过滤掉 input 字段★
□ ★登录限流★
□ ★ProxyHeaders 配置正确★
□ ★pip-audit 定期扫★
★ ★三分钟自查★:
curl -I https://api.example.com/health | grep -iE \
"strict-transport|x-content-type|x-frame|referrer"
curl -i -X OPTIONS https://api.example.com/items \
-H "Origin: https://evil.com" \
-H "Access-Control-Request-Method: POST"
# ★看会不会回显 evil.com★
curl -I https://api.example.com/docs # ★看生产是不是 404★
★ 一句话总结:
★"CORS 是浏览器机制、配它是放宽限制而不是加固——它防不住 CSRF,
也拦不住 curl;credentials=True 时 origins 不能是 *,
前端要读的自定义响应头必须写进 expose_headers;
CORS 中间件要放最外层否则 500 响应没有 CORS 头;
安全头 FastAPI 没有内置要自己加;生产记得关 /docs 和 /openapi.json。"★
标准配置模板里最关键的是中间件顺序:后 add 的先执行,所以 CORS 要最后 add 放在最外层。三分钟自查很实用:curl -I 看安全头齐不齐、用伪造的 Origin 试探会不会被回显、以及确认生产环境 /docs 返回 404。
记忆钩子:「★CORS 是浏览器的同源策略机制,配置它是『放宽限制』而不是『增加安全』★——同源策略限制的是★JS 读取跨源响应★,★请求本身照样能发出去★(这正是 CSRF 能成立的原因)。★JSON API 基本都会触发预检★(Content-Type: application/json 不属于简单请求的三种类型),★预检让请求数翻倍所以 max_age 很有价值★。★三个必记的配置点★:★① allow_credentials=True 时 allow_origins 绝不能是通配符★(CORS 规范硬性规定,浏览器直接拒绝);★② expose_headers 不配的话前端 JS 读不到自定义响应头★(浏览器默认只暴露七个简单响应头,你的 X-Total-Count/X-Request-Id/Location 跨域下★全都拿不到而且不报错,只是返回 null★);★③ 自己写中间件时不能无脑回显 Origin★(等于全开),要白名单校验后回显★并加 Vary: Origin★(否则 CDN 会把 A 站的响应缓存后发给 B 站)。★最重要的认知:CORS 不是安全机制★——它保护『用户数据不被恶意站点的 JS 读走』,★完全不保护服务端★(curl/Postman/服务端请求都不受约束),★真正的访问控制靠认证鉴权★。★CORS 防不住 CSRF★:攻击者的表单提交是简单请求、不触发预检、★服务端正常执行了转账★,CORS 只拦住了『读响应』。★判断要不要 CSRF 防护的唯一标准:凭证是不是浏览器自动携带的★——★Cookie 必须防、Authorization: Bearer 天然免疫★(那个头必须 JS 显式设置,攻击者读不到你的 token);用 Cookie 时靠 ★SameSite=Lax(浏览器默认)★ + ★双提交 Cookie★,★但前后端完全不同域时必须 SameSite=None+Secure,此时 SameSite 不再防 CSRF★。★中间件顺序容易搞错:后 add 的先执行(像栈)★,★CORS 要最后 add 放最外层,否则 500 响应不带 CORS 头,前端只能看到『CORS error』而看不到真正的错误★。另一个隐蔽坑:★预检请求不带认证信息,鉴权中间件如果对所有请求要 token 会让 OPTIONS 返回 401 导致 CORS 失败★。★安全头 FastAPI 没有内置要自己写纯 ASGI 中间件★(BaseHTTPMiddleware 会破坏流式):★纯 API 需要 nosniff(防 JSON 被当 HTML 执行)+ HSTS + Referrer-Policy,CSP 和 X-Frame-Options 对纯 API 意义不大★。FastAPI 特有的两项:★生产关闭 /docs 时 openapi_url 也要一起关★(否则能直接下载完整 schema)、★Pydantic V2 的 422 错误含 input 字段会回显用户输入(密码可能出现在错误响应里)要自定义处理器过滤★。还有 ★TrustedHostMiddleware 防 Host 头投毒★(注意用 IP 做健康检查会被拒)。★能同域部署就同域(Nginx 按路径分流),根本不需要 CORS★。」
七、常见误区与追问
- 误区:配好 CORS 白名单,就只有白名单里的站点能调用我的 API 了。 CORS 完全不保护服务端。它是浏览器执行的机制——浏览器在收到跨源响应后,检查
Access-Control-Allow-Origin头决定「要不要把响应内容交给页面 JS」。这意味着:curl、Postman、Python 的 requests、其他服务端程序、以及任何非浏览器客户端,都完全不受 CORS 约束——它们该拿到什么数据还是拿到什么数据。所以「配了 CORS 白名单」和「只有白名单能访问」是两回事,真正的访问控制必须靠认证(token/签名)和鉴权。CORS 保护的对象其实是你的用户:防止用户在访问恶意网站时,那个网站的 JS 用用户的身份读走你的 API 数据。理解这一点也就明白了为什么「把 API 设成allow_origins=["*"]」在纯 Bearer Token 认证下并不是致命问题(因为攻击者的页面拿不到 token,发不出有效请求),但在 Cookie 认证下就是灾难。 - 误区:前端拿不到
X-Total-Count响应头,是后端没返回。 大概率返回了,只是被浏览器挡住了。跨域场景下,浏览器默认只把七个「简单响应头」暴露给 JS:Cache-Control、Content-Language、Content-Length、Content-Type、Expires、Last-Modified、Pragma。其他任何头——包括你精心设计的X-Total-Count(分页总数)、X-Request-Id(排障用)、X-RateLimit-Remaining,甚至 201 创建后的Location头——在跨域请求下前端统统读不到,而且不会有任何报错,resp.headers.get("X-Total-Count")就是返回null。解法是在CORSMiddleware里配置expose_headers=["X-Total-Count", "X-Request-Id", "Location"]。排查这类问题的方法:在浏览器 Network 面板里能看到这个头(浏览器收到了),但 JS 读不到——这个现象就是expose_headers没配。 - 误区:用了 JWT +
Authorization头,还是要做 CSRF 防护才保险。 不需要,而且加了反而增加复杂度。CSRF 攻击成立的唯一前提是「浏览器会自动为目标域附上凭证」——Cookie 就是这样的凭证(无论请求从哪个页面发出,浏览器都会自动带上)。而Authorization: Bearer <token>这个请求头必须由 JavaScript 显式设置:攻击者的页面无法读取你存在 localStorage 或内存里的 token(同源策略保护),也无法让浏览器自动附加它。所以纯 Bearer Token 认证的 API 天然免疫 CSRF。但有两个必须警惕的例外:① 如果你把 token 存在 Cookie 里(哪怕是自定义名字的 cookie,哪怕前端会读出来再放进头里),只要后端接受从 Cookie 里读取的 token,CSRF 就又回来了;② 混合认证模式——同一个应用既支持 Cookie session(给网页用)又支持 Bearer(给 API 用),那么走 Cookie 的那部分必须防护。 - 误区:
@app.middleware("http")加安全响应头最方便,用它就行。 简单场景没问题,但它基于BaseHTTPMiddleware,有几个已知副作用。最严重的是破坏流式响应——它内部会把下游的响应体完整收集后再转发,导致StreamingResponse失去流式效果:SSE 推送变成一次性返回、大文件下载先在内存里攒齐、LLM 流式输出全部堆到最后才出来。如果你的应用里有任何流式接口(现在 AI 应用几乎必有),这就是个隐形炸弹。其次是性能开销(内部用了额外的任务和内存流做桥接)和异常传播行为的差异。推荐写纯 ASGI 中间件:实现async def __call__(self, scope, receive, send),用一个send_wrapper包装send,在http.response.start消息里追加响应头——这样逐条转发消息、完全不缓冲响应体,性能也更好。 - 误区:中间件的注册顺序不重要,反正都会执行。 顺序决定了嵌套关系,而且和直觉相反:后
add的先执行(像栈——最后加的在最外层)。这在两个场景下会造成实际问题。① CORS 头丢失:如果 CORS 中间件不在最外层,那么当内层的中间件或异常处理器返回错误响应时(比如 500、或者鉴权中间件返回 401),这个响应不会经过 CORS 中间件,也就没有Access-Control-Allow-Origin头——浏览器会把它报成「CORS error」而不是显示真正的状态码和错误信息,前端开发者会以为是跨域配置问题,实际上是后端报了 500,排查方向完全跑偏。所以 CORS 应该最后add。② 预检被鉴权拦截:OPTIONS 预检请求不带任何认证信息(浏览器规定),如果鉴权中间件在 CORS 之外或者对所有请求都要求 token,预检就会返回 401,导致真实请求根本发不出去。解法同样是把 CORS 放最外层,或在鉴权逻辑里跳过OPTIONS方法。 - 追问:生产环境到底该不该关掉
/docs? 默认应该关,除非有明确理由保留。暴露 Swagger UI 意味着:① 完整的接口清单——包括那些你以为「没人知道」的内部接口、管理接口;② 精确的参数结构和数据模型——攻击者不用猜字段名,直接照着 schema 构造请求;③ 「Try it out」按钮可以直接发起调用(虽然仍受认证限制,但降低了探测门槛);④ 版本信息可能暴露已知漏洞。关闭方式是FastAPI(docs_url=None, redoc_url=None, openapi_url=None)——注意openapi_url必须一起关,否则攻击者直接下载/openapi.json就拿到了全部信息(这是最常见的疏漏)。如果团队确实需要在生产查文档,两个折中方案:① 加鉴权——自己写/docs路由,用Depends(get_admin_user)保护,同时openapi_url也要保护;② 只在内网暴露——用 Nginx 的allow/deny限制来源 IP,或者把文档部署在单独的内网域名下。 - 追问:JWT 存 localStorage 还是 HttpOnly Cookie? 没有完美答案,是两种风险之间的权衡。localStorage:✓ 天然免疫 CSRF(浏览器不会自动带上,必须 JS 主动读取并放进请求头);✗ XSS 能直接偷走 token(任何注入的脚本都能
localStorage.getItem("token")然后发到攻击者服务器,而且 token 通常有效期较长)。HttpOnly Cookie:✓ XSS 偷不走(JS 读不到);✗ 需要 CSRF 防护(SameSite + Token)、跨域配置麻烦(SameSite=None+Secure、Safari ITP 可能屏蔽第三方 Cookie)。关键认知是:如果站点有 XSS 漏洞,两种方案其实都完蛋——因为攻击者的脚本可以直接以用户身份发请求(HttpOnly Cookie 会被自动带上),根本不需要「偷走 token」。所以首要任务永远是防 XSS(输出转义、CSP、依赖审计)。现实中较稳妥的组合是:短期 access token(15 分钟)存内存/JS 变量 + 长期 refresh token 存 HttpOnly + SameSite Cookie——这样即使 access token 被 XSS 偷走,损失窗口也很短;refresh 走 Cookie 且有 CSRF 保护。 - 追问:怎么系统地调试 CORS 问题? 五步,从最快到最慢。① 看浏览器控制台的完整错误信息——现代浏览器写得很清楚:「No ‘Access-Control-Allow-Origin’ header is present」(后端没配或请求没到)、「The value … must not be the wildcard ’*’ when credentials mode is ‘include’」(
*和 credentials 冲突)、「Method PUT is not allowed by Access-Control-Allow-Methods」(allow_methods缺)。② 看 Network 面板里的 OPTIONS 请求——它的状态码是多少?返回了哪些头?如果 OPTIONS 返回 401,说明预检被鉴权拦截了;如果根本没有 OPTIONS 请求,说明这是简单请求,问题在响应头上。③ 用 curl 模拟预检:curl -i -X OPTIONS https://api.x.com/items -H "Origin: https://app.x.com" -H "Access-Control-Request-Method: POST"——直接看返回的头对不对。顺便用一个伪造的恶意 Origin 试探,确认不会被无脑回显。④ 检查allow_origins的字面值——最常见的低级错误是多了结尾的斜杠("https://example.com/"),而 Origin 头永远是scheme://host:port不带路径。⑤ 确认反代转发了 Origin 头。要记住:CORS 完全是浏览器行为,用 curl 直接请求业务接口永远会「成功」——所以必须用带 Origin 头的方式模拟。
八、加强记忆
CORS 是浏览器的同源策略机制,配置它是「放宽限制」而不是「增加安全」——同源策略限制的是 JS 读取跨源响应,请求本身照样能发出去(这正是 CSRF 能成立的原因)。JSON API 基本都会触发预检(Content-Type: application/json 不属于简单请求的三种类型),预检让请求数翻倍所以 max_age 很有价值。三个必记的配置点:① allow_credentials=True 时 allow_origins 绝不能是 *(CORS 规范硬性规定,浏览器直接拒绝);② expose_headers 不配的话前端 JS 读不到自定义响应头(浏览器默认只暴露七个简单响应头,你的 X-Total-Count/X-Request-Id/Location 在跨域下全都拿不到,而且不报错、只是返回 null);③ 自己写中间件时不能无脑回显 Origin(等于全开),要白名单校验后再回显并加 Vary: Origin(否则 CDN 会把 A 站的响应缓存后发给 B 站)。最重要的认知是:CORS 不是安全机制——它保护「用户数据不被恶意站点的 JS 读走」,完全不保护服务端(curl、Postman、服务端请求都不受约束),真正的访问控制靠认证和鉴权。CORS 防不住 CSRF:攻击者的表单提交是简单请求、不触发预检、服务端正常执行了转账,CORS 只拦住了「读响应」。判断要不要 CSRF 防护的唯一标准是「凭证是不是浏览器自动携带的」——Cookie 必须防、Authorization: Bearer 天然免疫(那个头必须由 JS 显式设置,攻击者读不到你的 token);用 Cookie 时靠 SameSite=Lax(浏览器默认) 加双提交 Cookie,但前后端完全不同域时必须 SameSite=None + Secure,此时 SameSite 不再提供 CSRF 防护。中间件顺序容易搞错:后 add 的先执行(像栈)——CORS 要最后 add 放在最外层,否则 500 响应不带 CORS 头,前端只能看到「CORS error」而看不到真正的错误。另一个隐蔽的坑:预检请求不带认证信息,鉴权中间件如果对所有请求要求 token,会让 OPTIONS 返回 401 从而导致 CORS 失败。安全响应头 FastAPI 没有内置,要自己写纯 ASGI 中间件(BaseHTTPMiddleware 会破坏流式):纯 API 需要 nosniff(防 JSON 被当成 HTML 执行)+ HSTS + Referrer-Policy,而 CSP 和 X-Frame-Options 对纯 API 意义不大。FastAPI 特有的两项:生产关闭 /docs 时 openapi_url 也要一起关(否则能直接下载完整 schema)、Pydantic V2 的 422 错误含 input 字段会回显用户输入(密码可能出现在错误响应里),要自定义处理器过滤。还有 TrustedHostMiddleware 防 Host 头投毒(注意用 IP 做健康检查会被拒)。最后一条实用建议:能同域部署就同域(Nginx 按路径分流),根本不需要 CORS。