1. 当 Host 头变成路径BadHost 到底在攻击什么CVE-2026-48710代号 BadHost是 Starlette 在重建request.url时对 Host 头缺乏字符校验导致的主机头注入漏洞。它能让一个远程请求在中间件眼里是/evil在路由匹配时却命中/admin从而绕过几乎所有基于request.url.path的权限判断。受影响的是 Starlette ≤ 1.0.0修复版本为 1.0.1。FastAPI、vLLM、LiteLLM、MCP 服务器、Ray Serve、BentoML 这些 Python AI 生态里的常客只要间接依赖了旧版 Starlette就都在这条攻击链的射程内。这个漏洞适合谁关注如果你正在用 FastAPI 写 AI 网关、用 LiteLLM 做多模型代理、用 vLLM 暴露推理接口或者用 FastMCP 搭 Agent 工具服务那它就不是别人的漏洞。它的触发成本极低一个?字符就能让鉴权中间件失效。更麻烦的是很多开发者根本不知道request.url是基于用户可控的 Host 头重建出来的误以为它和scope[path]一样可信。我试过在本地起一个最小 FastAPI 服务复现从正常请求被 403 拦截到加上Host: 127.0.0.1:8000/?后直接返回敏感数据整个过程不到五分钟。这篇文章会带你走完复现、加固、验证三步并给出一个可复制的settings.json骨架把 TaoToken 统一 Key 通道接进来让请求校验和模型调用都收敛到同一条可控链路上。2. 复现环境与 TaoToken 前置准备2.1 最小复现服务先搭一个有漏洞的服务用request.url.path做权限判断这是最典型的错误写法。# vulnerable_app.py from fastapi import FastAPI, Request, HTTPException from starlette.middleware.base import BaseHTTPMiddleware app FastAPI(titleBadHost Vulnerable Demo) class PathBasedAuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): protected_paths [/admin, /internal] for path in protected_paths: if request.url.path.startswith(path): if request.client.host ! 127.0.0.1: raise HTTPException(status_code403, detailForbidden) return await call_next(request) app.add_middleware(PathBasedAuthMiddleware) app.get(/) async def public_endpoint(): return {message: public} app.get(/admin) async def admin_endpoint(): return {message: Sensitive admin data, api_key: sk-xxxx} app.get(/internal/config) async def internal_config(): return {config: internal service configurations}安装并启动pip install fastapi0.115.0 starlette1.0.0 uvicorn uvicorn vulnerable_app:app --host 0.0.0.0 --port 80002.2 TaoToken 统一 Key 通道准备复现完之后我们需要一条可控的模型调用链路来验证加固效果。TaoToken 提供统一的 Key 与 API 通道官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 基址为 https://taotoken.net/api 。你需要先在控制台创建一个 API Key控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite Key 管理页在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 模型对话调试页在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。拿到 Key 之后我们把它写进一个settings.json作为服务端统一读取的配置骨架。这个文件同时承载三件事Host 白名单、TaoToken 通道参数、以及请求校验开关。3. 可复制的 settings.json 与反向代理加固骨架3.1 settings.json 加固骨架{ server: { allowed_hosts: [yourdomain.com, localhost, 127.0.0.1], reject_host_special_chars: true, trusted_proxy_ips: [127.0.0.1] }, taotoken: { base_url: https://taotoken.net/api, api_key_env: TAOTOKEN_API_KEY, default_model: claude-sonnet-4-20250514, timeout_seconds: 60, max_retries: 2 }, security: { use_scope_path_for_auth: true, enable_host_middleware: true, log_invalid_host: true } }几个关键点allowed_hosts是白名单不在列表里的 Host 直接拒绝reject_host_special_chars打开后任何包含/ ? # \空格或控制字符的 Host 头都会被 400 掉use_scope_path_for_auth强制权限判断走request.scope[path]而不是request.url.path。taotoken.api_key_env指向环境变量名避免把 Key 硬编码进文件。3.2 安全中间件实现# secure_middleware.py import json import os from fastapi import Request, JSONResponse from starlette.middleware.base import BaseHTTPMiddleware with open(settings.json, r, encodingutf-8) as f: CONFIG json.load(f) SPECIAL_CHARS set(/?#\\ \t\n\r) class SecureHostMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): host request.headers.get(host, ) if CONFIG[server][reject_host_special_chars]: if any(c in host for c in SPECIAL_CHARS): if CONFIG[security][log_invalid_host]: print(f[BadHost] rejected host{host!r}) return JSONResponse( status_code400, content{error: Invalid Host header} ) hostname host.split(:)[0] if hostname not in CONFIG[server][allowed_hosts]: return JSONResponse( status_code400, content{error: Host not allowed} ) return await call_next(request)权限判断部分改成# 错误易受 BadHost 影响 if request.url.path.startswith(/admin): # 正确使用 ASGI scope 原始路径 if request.scope[path].startswith(/admin):3.3 Nginx 反向代理加固server { listen 80; server_name yourdomain.com; if ($host ~* [/?#\\ \t\n\r]) { return 400; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }Nginx 在入口就把非法 Host 拦掉ASGI 服务只接受来自127.0.0.1的流量形成双层防护。4. 验证请求从恶意 Host 到 TaoToken 通道校验4.1 复现恶意 Host 注入先确认漏洞存在。正常请求curl -i http://127.0.0.1:8000/admin返回 403。加上恶意 Hostcurl -i http://127.0.0.1:8000/admin -H Host: 127.0.0.1:8000/?在未修复的 Starlette 1.0.0 上这会返回 200 和敏感数据。原因是request.url.path被重建为/中间件认为它不在保护列表里而路由匹配用的是scope[path]仍然是/admin。4.2 加固后验证升级 Starlette 并挂上SecureHostMiddleware后同样的恶意请求curl -i http://127.0.0.1:8000/admin -H Host: 127.0.0.1:8000/?返回 400Invalid Host header。正常请求curl -i http://127.0.0.1:8000/admin -H Host: 127.0.0.1:8000返回 403权限判断依然生效。4.3 TaoToken 通道校验加固完成后用 TaoToken 统一 Key 通道做一次端到端验证确认模型调用链路正常。先导出 Keyexport TAOTOKEN_API_KEY你的Key然后用 Python 发一个最小请求import os import json import urllib.request with open(settings.json, r, encodingutf-8) as f: cfg json.load(f) api_key os.environ[cfg[taotoken][api_key_env]] url f{cfg[taotoken][base_url]}/v1/chat/completions payload { model: cfg[taotoken][default_model], messages: [{role: user, content: ping}], max_tokens: 16 } req urllib.request.Request( url, datajson.dumps(payload).encode(), headers{ Authorization: fBearer {api_key}, Content-Type: application/json }, methodPOST ) with urllib.request.urlopen(req, timeoutcfg[taotoken][timeout_seconds]) as resp: print(resp.status) print(resp.read().decode()[:200])返回 200 和模型响应说明 TaoToken 通道可用。你也可以在模型对话页 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 手动发一条消息做交叉验证。5. 本篇常见错排查升级后仍然被绕过检查是否有多层中间件顺序问题。SecureHostMiddleware必须加在权限中间件之前否则权限判断先执行恶意 Host 已经生效。用app.add_middleware时注意后添加的先执行。Nginx 返回 400 但服务端没日志说明请求在代理层就被拦了这是预期行为。如果想让服务端也记录把 Nginx 的return 400改成proxy_pass并在应用层拦截但这样会牺牲一层防护。request.scope[path]和request.url.path在正常请求下不一致正常请求下两者应该一致。如果不一致说明有代理做了路径重写检查proxy_set_header和X-Forwarded-Prefix配置。TaoToken 请求返回 401确认TAOTOKEN_API_KEY环境变量已导出且 Key 没有多余空格。可以在 API Keys 页 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 重新生成一个测试 Key。pip 升级 Starlette 后 FastAPI 报版本冲突FastAPI 对 Starlette 有版本上限约束。先升级 FastAPI 到最新版再升级 Starlette。如果仍有冲突用pip install starlette1.0.1 --upgrade --force-reinstall强制覆盖然后跑一遍测试。LiteLLM 代理场景LiteLLM 1.74.2 到 1.83.6 受 BadHost 与 CVE-2026-42271 利用链影响升级到 1.83.7 以上。升级后检查/mcp-rest/test/connection端点是否已加认证。6. 把校验链路收敛到 TaoToken 统一通道BadHost 的核心教训不是升级一个库而是不要信任任何用户可控输入重建出来的对象。request.url是重建的request.headers[host]是用户可控的只有scope[path]和scope[server]才是 ASGI 服务器解析后的原始值。把权限判断、日志记录、限流策略全部切到scope上是这次加固的底线。对于长期跑编码任务和 Agent 服务的团队建议把模型调用也收敛到统一通道。TaoToken 的 Coding Plan 入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 适合需要稳定长连接的场景。Claude Code 相关接入参考 https://taotoken.net/claudecode-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecode-anthropicutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的请求示例和错误码说明。最后留一个实操建议把settings.json里的allowed_hosts和taotoken.base_url做成环境变量覆盖这样本地调试、预发、生产可以用同一份代码只换配置。我踩过的坑是把allowed_hosts写死成localhost结果容器里跑的时候 Host 是服务名直接被自己的中间件拦了。