Starlette 是 Python 生态中用量最大的 Web 框架之一——每周下载量 3.25 亿,FastAPI、Uvicorn 等基础设施都直接依赖它。最近披露的 BadHost 漏洞(高危)揭示了一个容易被忽视的问题:当路径级访问控制遇上畸形的 HTTP Host 头,防线会从根部断裂。对于暴露在公网的 AI Agent 服务、LLM Gateway 和评测系统来说,这不是理论风险——这些服务普遍用 Starlette/FastAPI 搭建,且访问控制往往只做到路径级别。
漏洞根因:Host 头与路径路由的信任错位
Starlette 的路由匹配依赖请求路径(scope["path"]),而访问控制中间件通常也只检查路径前缀,比如 /admin/* 需要认证、/agent/internal/* 禁止外部访问。问题在于,HTTP 请求的最终路径并不是只由客户端发送的 URI 决定——它还受 Host 头影响。
当 Host 头被故意构造为畸形值时(例如嵌入路径片段、非标准字符),部分代理、网关和 Starlette 自身的路径解析逻辑会产生不一致的结果。攻击者发送的请求在中间件看来访问的是合法路径,但在路由层实际到达了受保护资源。这就是 BadHost 的核心机制:利用 Host 头畸形值制造路径认知分歧,绕过只看路径的访问控制。
受影响的 AI 基础设施场景
AI Agent 服务、LLM Gateway 和评测平台有几个共同特征,使它们尤其脆弱:
- 多路由共存:同一个服务同时暴露公开 API(
/chat/completions)和内部管理接口(/agent/config、/eval/results)。 - 路径级鉴权:用中间件按路径前缀决定是否要求认证,而非对每个路由单独鉴权。
- 公网可达:Agent 服务需要接收外部调用,管理接口本应只对内部开放,但部署在同一进程。
一个典型的 FastAPI 应用结构如下,其中 internal_router 只做了路径前缀隔离,没有独立鉴权:
# vulnerable_app.py — 展示 BadHost 风险的典型结构
from fastapi import FastAPI
from fastapi.middleware import Middleware
app = FastAPI()
# 公开接口
@app.post("/chat/completions")
async def chat_completion(prompt: str):
return {"response": "..."}
# 内部管理接口 — 仅靠路径前缀隔离
@app.get("/agent/config")
async def get_agent_config():
return {"model": "gpt-4", "tools": ["search", "code"]}
@app.get("/eval/results")
async def get_eval_results():
return {"scores": [0.92, 0.87, 0.95]}
# 路径级访问控制中间件(只检查 path)
@app.middleware("http")
async def path_based_auth(request, call_next):
path = request.url.path
if path.startswith("/agent") or path.startswith("/eval"):
# 这里只看了 path,没验证 Host 的合法性
# BadHost 可以让这个 path 检查看到的是 /chat/completions
# 而实际路由到达的是 /agent/config
if not request.headers.get("Authorization"):
from fastapi.responses import JSONResponse
return JSONResponse(status_code=401, content={"error": "unauthorized"})
return await call_next(request)
上面的中间件只检查 request.url.path,没有对 Host 头做任何校验。在 BadHost 场景下,攻击者可以构造畸形 Host 头,让中间件看到的路径和路由层实际匹配的路径不一致。
攻击演示:用畸形 Host 头绕过路径鉴权
以下脚本模拟 BadHost 攻击的核心手法——发送带有畸形 Host 头的请求,尝试绕过路径级访问控制访问内部接口:
# badhost_probe.sh — 检测你的服务是否受 BadHost 影响
# 用法: bash badhost_probe.sh <target_host> <target_port>
# 注意: 仅用于对自己服务的安全测试,勿用于未授权系统
TARGET_HOST="${1:-localhost}"
TARGET_PORT="${2:-8000}"
echo "=== BadHost 漏洞探测 ==="
echo "目标: ${TARGET_HOST}:${TARGET_PORT}"
# 1. 正常请求 — 应返回 401
echo ""
echo "[1] 正常请求 /agent/config(无 Auth 头):"
curl -s -o /dev/null -w "HTTP %{http_code}" \
"http://${TARGET_HOST}:${TARGET_PORT}/agent/config"
# 2. 畸形 Host 头 — 带路径片段
echo ""
echo "[2] 畸形 Host: 嵌入路径片段:"
curl -s -o /dev/null -w "HTTP %{http_code}" \
-H "Host: ${TARGET_HOST}/chat/completions" \
"http://${TARGET_HOST}:${TARGET_PORT}/agent/config"
# 3. 畸形 Host 头 — 非标准字符
echo ""
echo "[3] 畸形 Host: 非标准字符注入:"
curl -s -o /dev/null -w "HTTP %{http_code}" \
-H "Host: ${TARGET_HOST}%2fchat%2fcompletions" \
"http://${TARGET_HOST}:${TARGET_PORT}/agent/config"
# 4. Host 头包含端口欺骗
echo ""
echo "[4] 畸形 Host: 端口欺骗:"
curl -s -o /dev/null -w "HTTP %{http_code}" \
-H "Host: ${TARGET_HOST}:${TARGET_PORT};path=/chat/completions" \
"http://${TARGET_HOST}:${TARGET_PORT}/agent/config"
echo ""
echo ""
echo "如果任何畸形 Host 请求返回 200 而非 401,你的服务受 BadHost 影响。"
运行方式:
# 启动测试服务
pip install fastapi uvicorn
python vulnerable_app.py & # 或 uvicorn vulnerable_app:app &
# 探测
bash badhost_probe.sh localhost 8000
修复方案:多层防御
BadHost 的教训是:不要只靠路径做访问控制。修复需要从三个层面入手。
第一层:严格校验 Host 头
在中间件最早的位置加入 Host 头白名单校验,拒绝任何不符合预期的请求:
# fixed_app.py — 修复后的版本
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import re
ALLOWED_HOSTS = {"api.yourcompany.com", "localhost"} # 改为你的实际域名
app = FastAPI()
@app.middleware("http")
async def validate_host_then_auth(request: Request, call_next):
# 第一关: Host 头白名单
host = request.headers.get("host", "").split(":")[0] # 去掉端口
if host not in ALLOWED_HOSTS:
return JSONResponse(status_code=400, content={"error": "invalid host"})
# 第二关: 路径级鉴权(现在 Host 已验证,路径认知不会分歧)
path = request.url.path
if path.startswith("/agent") or path.startswith("/eval"):
if not request.headers.get("Authorization"):
return JSONResponse(status_code=401, content={"error": "unauthorized"})
return await call_next(request)
# 公开接口
@app.post("/chat/completions")
async def chat_completion(prompt: str):
return {"response": "..."}
# 内部接口
@app.get("/agent/config")
async def get_agent_config():
return {"model": "gpt-4", "tools": ["search", "code"]}
第二层:路由级鉴权,而非路径前缀级
不要用中间件按前缀拦截,而是对每个敏感路由单独加依赖项鉴权:
from fastapi import Depends, HTTPException
def require_auth(authorization: str = None):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401)
# 实际验证 token...
return authorization
# 每个内部路由单独鉴权,不依赖中间件的前缀匹配
@app.get("/agent/config", dependencies=[Depends(require_auth)])
async def get_agent_config():
return {"model": "gpt-4", "tools": ["search", "code"]}
第三层:网络层隔离
最可靠的方案是把内部接口从公网服务中彻底拆出来:
# docker-compose.yml — 内部接口走独立端口,不暴露公网
services:
ai-gateway:
build: .
ports:
- "443:8000" # 公网只到公开 API
environment:
- SERVE_INTERNAL=false
command: uvicorn app:app --host 0.0.0.0 --port 8000
internal-api:
build: .
ports:
- "127.0.0.1:8001:8001" # 只绑定 localhost,外部不可达
environment:
- SERVE_PUBLIC=false
command: uvicorn internal_app:app --host 0.0.0.0 --port 8001
自检清单
部署 AI Agent 服务或 LLM Gateway 前,逐项确认:
| 检查项 | 状态 |
|---|---|
| Starlette/FastAPI 版本是否包含 BadHost 补丁 | ☐ |
| 是否有中间件仅靠路径前缀做鉴权 | ☐ |
| Host 头是否有白名单校验 | ☐ |
| 内部管理接口是否与公开 API 在同一进程 | ☐ |
| 内部接口是否可通过公网端口直接访问 | ☐ |
| 是否对每个敏感路由单独加了鉴权依赖 | ☐ |
BadHost 暴露的不是某个罕见边界条件,而是一个系统性设计盲区:当安全决策依赖的输入字段(路径)可以被另一个不受控的输入字段(Host)间接篡改时,防线就是虚的。对于 AI 基础设施来说,内部接口泄露的后果远不止数据外泄——Agent 配置、评测结果、模型参数都可能被篡改或窃取,进而影响下游所有依赖该 Agent 的业务。升级框架版本是第一步,但真正解决问题需要重新审视访问控制的架构:从路径前缀拦截走向路由级鉴权,从单进程混合部署走向网络层隔离。