← 返回题目列表

FastAPI 怎么配 CORS 和安全响应头?前后端分离要注意什么?

中等 第 24 / 27 题 更新于 2026/08/03
FastAPICORS安全头中间件CSRF

简化版

CORS 是浏览器的同源策略机制,配置它是「放宽限制」而不是「增加安全」——FastAPI 用 CORSMiddleware 配置:allow_origins必须写具体域名,不要用 *)、allow_credentials(带 Cookie 时开,开了之后 allow_origins 绝不能是 *,浏览器会直接拒绝)、allow_methodsallow_headers、以及 expose_headers(不写的话前端读不到自定义响应头,比如 X-Total-CountJSON 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: nosniffX-Frame-Options: DENYReferrer-PolicyStrict-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=Trueallow_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-ControlContent-LanguageContent-LengthContent-TypeExpiresLast-ModifiedPragma)——你返回的 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-PolicyCSP 和 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 响应头,是后端没返回。 大概率返回了,只是被浏览器挡住了。跨域场景下,浏览器默认只把七个「简单响应头」暴露给 JSCache-ControlContent-LanguageContent-LengthContent-TypeExpiresLast-ModifiedPragma。其他任何头——包括你精心设计的 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 包装 sendhttp.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=Trueallow_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 特有的两项:生产关闭 /docsopenapi_url 也要一起关(否则能直接下载完整 schema)、Pydantic V2 的 422 错误含 input 字段会回显用户输入(密码可能出现在错误响应里),要自定义处理器过滤。还有 TrustedHostMiddleware 防 Host 头投毒(注意用 IP 做健康检查会被拒)。最后一条实用建议:能同域部署就同域(Nginx 按路径分流),根本不需要 CORS