FastAPI 中间件的洋葱模型倒序陷阱

举报
yd_283294818 发表于 2026/10/30 11:38:46 2026/10/30
【摘要】 FastAPI 中间件的洋葱模型倒序陷阱:为什么你的 OPTIONS 预检请求会报 401? 一个看似正常的中间件配置在开发基于 FastAPI / Starlette 的 Web 服务时,认证和跨域(CORS)几乎是每个项目的标准配置。很多人习惯像下面这样写:from fastapi import FastAPIfrom fastapi.middleware.cors import CO...

FastAPI 中间件的洋葱模型倒序陷阱:为什么你的 OPTIONS 预检请求会报 401?

一个看似正常的中间件配置

在开发基于 FastAPI / Starlette 的 Web 服务时,认证和跨域(CORS)几乎是每个项目的标准配置。很多人习惯像下面这样写:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from my_auth import BearerTokenAuthMiddleware

app = FastAPI()

# 1. 允许跨域
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 2. 接口鉴权
app.add_middleware(BearerTokenAuthMiddleware)

直觉上看,这段代码完全符合直觉:先配置 CORS 允许前端跨域访问,再配置自定义的 Token 鉴权中间件保护接口。

但是在真实浏览器联调时,前端发起带有自定义请求头(比如 Authorization)的跨域请求时,浏览器控制台却会直接抛出 CORS 错误:

Access to XMLHttpRequest at 'http://api.example.com/data' from origin 'http://localhost:3000' 
has been blocked by CORS policy: Response to preflight request doesn't pass access control check: 
It does not have HTTP ok status.

查看网络面板,会发现浏览器自动发起的 OPTIONS 预检请求返回了 HTTP 401 Unauthorized,并且响应头里根本没有 Access-Control-Allow-Origin。

明明接口在 Postman 或 curl 里测试都很正常,为什么一到浏览器里就直接报 401?


根因分析:Starlette 的 add_middleware 是向外包裹的

FastAPI 底层基于 Starlette 构建,而 Starlette 的中间件机制采用了经典的 ASGI 包装器模型(也就是大家常说的“洋葱模型”)。

关键在于 Starlette 中 app.add_middleware(...) 的注册方式:后注册的中间件,会包裹在先注册的中间件外层,因此后注册的反而最先执行!

查看 Starlette 源码中中间件构建的逻辑(简化示意):

# Starlette 构建中间件链的顺序
def build_middleware_stack(self):
    app = self.router
    for cls, options in reversed(self.user_middleware):
        app = cls(app, **options)
    return app

当代码按以下顺序调用时:

  1. app.add_middleware(CORSMiddleware)
  2. app.add_middleware(BearerTokenAuthMiddleware)

实际生成的执行调用链是:

客户端请求 (Request)
      ↓
[ BearerTokenAuthMiddleware ]  ← 后注册,但在最外层,最先拦截!
      ↓
[ CORSMiddleware ]             ← 先注册,但在内层
      ↓
[ 路由处理业务函数 ]

为什么 OPTIONS 预检请求会遭遇灭顶之灾?

浏览器在跨域发送复杂请求(如包含 Authorization: Bearer xxx 头或使用非简单请求方法)前,会强制发送一个不带实际业务数据和鉴权凭据的 OPTIONS 方法探测请求(Preflight Pre-check)。

预检请求有其特殊性:

  • 按照 W3C 规范,浏览器在发送 OPTIONS 预检请求时,绝不会附带真实的业务 Authorization 请求头;
  • 预检请求的唯一目的,是询问服务器:“你允许哪些来源、哪些 Method 和哪些 Headers”;
  • 正常情况下,CORSMiddleware 在捕获到 OPTIONS 请求后,会直接检查并返回 HTTP 200 OK,并附带 Access-Control-Allow-Origin 等协商头,甚至不需要将请求交给后续的鉴权和业务逻辑。

但由于中间件顺序倒置:

  1. 请求进入服务,最先被最外层的 BearerTokenAuthMiddleware 捕获;
  2. 鉴权中间件检查请求头 request.headers.get("Authorization"),发现为空;
  3. 鉴权中间件直接短路,返回 JSONResponse(status_code=401, content={"error": "unauthorized"});
  4. 请求根本没有机会到达内层的 CORSMiddleware!
  5. 结果返回给浏览器的 401 响应中不含有任何 CORS 头,浏览器判定预检失败,拒绝发送真实的业务请求。

最小复现实验

我们用一个干净的最小 Python 脚本验证这个行为:

import hmac
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse

class BearerTokenAuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        token = request.headers.get("Authorization")
        if not token or not token.startswith("Bearer "):
            return JSONResponse(status_code=401, content={"error": "unauthorized"})
        return await call_next(request)

# 错误顺序:CORS 先加,Auth 后加
bad_app = FastAPI()
bad_app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_headers=["*"], allow_methods=["*"])
bad_app.add_middleware(BearerTokenAuthMiddleware)

client = TestClient(bad_app)
resp = client.options("/test", headers={
    "Origin": "http://example.com",
    "Access-Control-Request-Method": "POST",
    "Access-Control-Request-Headers": "authorization"
})
print(f"预检请求状态码: {resp.status_code}")
print(f"CORS 响应头: {resp.headers.get('access-control-allow-origin')}")

运行输出:

预检请求状态码: 401
CORS 响应头: None

浏览器端遇到这种情况必定报错。


正确的解法

解法一:调整中间件注册顺序(最简单也是最标准的解法)

只要颠倒两者的注册顺序即可:

app = FastAPI()

# 1. 先注册认证中间件(会落在内层)
app.add_middleware(BearerTokenAuthMiddleware)

# 2. 最后注册 CORS 中间件(会包裹在最外层)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

此时洋葱结构为:

请求进入 -> [ CORSMiddleware ] -> [ BearerTokenAuthMiddleware ] -> 路由函数

当 OPTIONS 预检请求到达时,最外层的 CORSMiddleware 立即拦截并返回带 CORS 头的 200 响应;当真实业务请求到来时,通过 CORS 后再由 BearerTokenAuthMiddleware 验证 Token。

解法二:在鉴权中间件中显式放行 OPTIONS 与健康检查路径

即使注册顺序正确,防御性编程依然推荐在自定义鉴权中间件中对非凭据请求做保护:

class BearerTokenAuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        # 1. 显式放行 CORS 预检请求
        if request.method == "OPTIONS":
            return await call_next(request)
        
        # 2. 显式放行容器存活探针(K8s / 负载均衡探活通常不带凭据)
        if request.url.path in ("/health", "/healthz", "/metrics"):
            return await call_next(request)

        # 3. 正常校验 Token
        token = request.headers.get("Authorization")
        if not self.is_valid_token(token):
            return JSONResponse(status_code=401, content={"error": "unauthorized"})

        return await call_next(request)

总结

  1. Starlette 中间件是洋葱模型,add_middleware 向外包裹。最后添加的中间件最先执行。
  2. CORSMiddleware 几乎总是应当作为最后一个添加的中间件,确保它在最外层第一时间拦截预检并给所有响应追加 CORS 头。
  3. 自定义鉴权中间件切忌盲目拦截所有无凭据请求,对 OPTIONS 和探活路由应当具备放行意识。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。