Starlette 是 Python Web 生态中用量最广的轻量框架之一——每周下载量 3.25 亿,FastAPI、Uvicorn 的底层 HTTP 处理都依赖它。而最近披露的 BadHost 漏洞(高危),让一个看似不起眼的 HTTP Host 头,变成了绕过路径级访问控制的钥匙。对于把 Starlette 当作 AI 代理、LLM 网关、模型评估服务入口的团队来说,这把钥匙直接通向敏感基础设施。
畸形 Host 头怎么绕过鉴权?
Starlette 的路径中间件(PathMiddleware)和基于路径的访问控制,依赖请求的 path 属性做匹配。问题出在 Starlette 解析 Host 头的方式:当客户端发送一个畸形 Host 值(例如包含路径分隔符 / 或特殊编码),框架在重建请求目标时,会把 Host 中的路径片段混入 scope["path"],导致实际路由匹配的路径与开发者预期不一致。
简单说:你以为中间件守住了 /admin/agents,攻击者用畸形 Host 头让框架看到的路径变成了 /public/something,鉴权规则直接跳过。
这不是理论推演——Starlette 的请求重建逻辑确实存在这段代码路径,且影响所有依赖 scope["path"] 做权限判断的上层框架,包括 FastAPI。
AI 代理和 LLM 网关为什么首当其冲
当前大量 AI 代理(Agent)服务、LLM 网关和模型评估平台用 FastAPI/Starlette 暴露 HTTP 接口。典型部署架构里:
- LLM 网关:统一入口,按路径区分
/v1/chat/completions(公开)和/admin/keys(内部),路径级鉴权是第一道防线。 - AI 代理服务:代理执行层暴露
/agent/run,管理接口暴露/agent/config,后者通常只允许内部调用。 - 评估器(Evaluator):接收模型输出做评分,
/evaluate公开,/evaluate/debug含敏感日志只限内部。
BadHost 让这些路径级防线失效。攻击者不需要拿到 token,只需要构造一个畸形 Host 头,就能访问本应受限的管理接口、密钥管理端点、代理配置面板。
复现与验证:用 curl 触发畸形 Host
以下示例展示如何用 curl 发送畸形 Host 头,验证你的 Starlette/FastAPI 服务是否受影响。请只在自己的测试环境运行。
# 正常请求 —— 被鉴权中间件拦截,返回 403
curl -v http://localhost:8000/admin/agents \
-H "Authorization: Bearer invalid-token"
# 畸形 Host 请求 —— Host 中嵌入路径片段,尝试让框架误判 path
curl -v http://localhost:8000/admin/agents \
-H "Host: legit-host.com/public/health"
# 另一种变体:Host 包含斜杠和编码
curl -v http://localhost:8000/admin/agents \
-H "Host: legit-host.com%2fpublic%2fhealth"
# 观察响应:如果畸形 Host 请求返回了 200 或非 403,
# 说明路径被错误解析,BadHost 漏洞存在
关键观察点:对比两个请求的响应状态码。如果畸形 Host 请求绕过了鉴权返回正常内容,服务存在漏洞。
修复方案:三层防御
第一层:升级 Starlette
关注 Starlette 官方发布的安全补丁版本,升级到修复 BadHost 的版本是根本解决。在 pyproject.toml 或 requirements.txt 中锁定补丁版本:
# pyproject.toml
[project]
dependencies = [
"starlette>=0.x.x", # 替换 x.x 为官方补丁版本号
]
# 确认当前版本
pip show starlette | grep Version
# 升级
pip install --upgrade starlette
第二层:中间件层严格校验 Host 头
在补丁发布前或作为纵深防御,加一层 Host 白名单中间件,直接拒绝畸形值:
# host_guard.py —— 添加到 FastAPI/Starlette 应用
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
import re
VALID_HOST_PATTERN = re.compile(r^[a-z0-9][a-z0-9\.\-]*[a-z0-9]$ # 仅允许合法域名字符
class HostGuardMiddleware(BaseHTTPMiddleware):
def __init__(self, app, allowed_hosts: set[str]):
super().__init__(app)
self.allowed_hosts = allowed_hosts
async def dispatch(self, request, call_next):
host = request.headers.get("host", "")
# 去掉可能的端口部分
hostname = host.split(":")[0]
# 拒绝包含斜杠、编码字符或不在白名单中的 Host
if "/" in host or "%" in host:
return Response("Malformed Host header", status_code=400)
if hostname not in self.allowed_hosts:
return Response("Host not allowed", status_code=403)
return await call_next(request)
# FastAPI 中挂载
from fastapi import FastAPI
app = FastAPI()
ALLOWED_HOSTS = {"api.yourcompany.com", "localhost"} # 按你的部署填写
app.add_middleware(HostGuardMiddleware, allowed_hosts=ALLOWED_HOSTS)
第三层:鉴权逻辑不要只依赖 path
路径级鉴权是便利的简化,但 BadHost 证明它不可靠。更稳健的做法:
- 敏感端点用独立鉴权装饰器:每个管理路由加
Depends(require_admin),不依赖中间件的全局路径匹配。 - 网关层做路由隔离:公开 API 和管理 API 分不同端口或不同服务暴露,中间用 nginx/Envoy 做硬隔离。
# 独立鉴权示例 —— 不依赖路径中间件
from fastapi import FastAPI, Depends, HTTPException
app = FastAPI()
def require_admin(token: str = Depends(...)):
# 实际项目中从 Header 提取并验证
if token != "expected-admin-secret":
raise HTTPException(status_code=403, detail="Forbidden")
# 管理端点:每个路由独立鉴权
@app.get("/admin/agents", dependencies=[Depends(require_admin)])
async def list_agents():
return {"agents": [...]}
# 公开端点:无额外鉴权
@app.get("/v1/chat/completions")
async def chat():
return {"response": "..."}
部署检查清单
上线前逐项确认:
| 检查项 | 操作 |
|---|---|
| Starlette 版本 | pip show starlette,确认已升级到补丁版本 |
| FastAPI 版本 | FastAPI 依赖 Starlette,同步升级 |
| Host 头校验 | 部署 HostGuardMiddleware 或 nginx 层 server_name 白名单 |
| 路径鉴权依赖 | 检查是否有路由仅靠中间件路径匹配做鉴权,补上独立鉴权 |
| 管理接口暴露范围 | /admin、/config、/keys 类端点是否与公开 API 同端口?考虑拆分 |
| 日志监控 | 添加 Host 头异常告警:记录包含 / 或 % 的 Host 值 |
BadHost 的核心教训:HTTP 头是客户端可控的输入,任何依赖它做安全判断的逻辑都必须做严格校验。在 AI 代理和 LLM 网关场景下,路径级访问控制是一道常见防线,但它比大多数人以为的更脆弱。升级框架、加 Host 白名单、让敏感端点自带独立鉴权——三层叠加,才是该有的纵深。