The BADHOST Bug: Every FastAPI and Starlette App Was Vulnerable and Most Developers Don’t Know It
CVE-2026–48710. One crafted HTTP header. Zero credentials required. Authentication bypassed. Disclosed May 2026, affecting Starlette <…
The BADHOST Bug: Every FastAPI and Starlette App Was Vulnerable and Most Developers Don’t Know It

CVE-2026–48710. One crafted HTTP header. Zero credentials required. Authentication bypassed. Disclosed May 2026, affecting Starlette < 1.0.1 — which means every FastAPI app, every LiteLLM deployment, every vLLM server, and most MCP backends ever built. Here’s the root cause, the exact attack, and the four ways to fix it.
Here is the attack in one sentence: send a request to a protected endpoint with a malformed Host header, and Starlette gives your middleware the wrong path — so the middleware passes you through.
No password. No token. No action from any victim. A single crafted header.
The vulnerability has a name: BadHost. A CVE number: CVE-2026–48710. A disclosure date: May 2026. A patch: Starlette 1.0.1.
And a blast radius that covers essentially every production Python AI deployment built in the last five years.
Who Discovered It and How
BadHost was found by X41 D-Sec during a source-code security audit sponsored by the Open Source Technology Improvement Fund (OSTIF). They weren’t auditing Starlette. They were auditing something else and traced the vulnerability to Starlette as the root cause. The finding was then coordinated with Starlette’s maintainer through a GitHub Security Advisory before public disclosure.
OSTIF’s post-disclosure summary put the situation plainly: “This bug is a classic ‘responsibility gap’ where if this maintainer didn’t patch, thousands of exposed projects would have to individually secure their projects.”
After the discovery, X41 published an open-source toolkit: a Python proof-of-concept exploit, Semgrep rules for static detection, and CodeQL queries for scanning request.url.path usage in middleware at scale. The toolkit is on GitHub at x41sec/poc/tree/master/starlette-host-header. An Internet-wide scan by Nemesis / Persistent Security is tracking real-world exposure.
The Root Cause: Two Parsers Disagreeing
The vulnerability is not in one line of code in one file. It emerges from the interaction between three independent layers, each of which behaves correctly in isolation.
Layer 1: ASGI servers pass the raw Host header through.
Uvicorn, Hypercorn, Daphne, Granian — every ASGI server forwards whatever Host value the client sent, without modification. That's correct behaviour: validating HTTP headers is not the job of the transport layer.
Layer 2: Starlette builds request.url by concatenating the Host header with the request path.
# Starlette internals — simplified
def _get_url(scope):
host = dict(scope["headers"]).get(b"host", b"").decode()
path = scope["path"] # From the HTTP request line — safe
return f"https://{host}{path}" # Host is user-controlled — not safe
The resulting URL is then re-parsed, which is where the injection works. The Host value is not validated against RFC 9112 or RFC 3986 grammar before being used for this construction.
Layer 3: Auth middleware checks request.url.path to decide whether to enforce authentication.
# Pattern in countless production codebases
class AuthMiddleware(BaseHTTPMiddleware):
EXEMPT_PATHS = ["/health", "/docs", "/openapi.json"]
async def dispatch(self, request: Request, call_next):
if request.url.path in self.EXEMPT_PATHS:
return await call_next(request)
# Enforce authentication here
token = request.headers.get("Authorization")
if not token or not validate_token(token):
return Response("Unauthorized", status_code=401)
return await call_next(request)
This pattern — exempting certain paths from authentication — is used everywhere: health checks, API docs endpoints, OAuth discovery endpoints. Every single piece of it is reasonable. The middleware author reasonably assumes request.url.path reflects the path the client requested. It does not.
The Attack, Step by Step
An attacker wants to reach /protected without credentials. The middleware blocks everything not in EXEMPT_PATHS. The attacker sends this request:
GET /protected HTTP/1.1
Host: example.com/health?x=
Here is what happens in Starlette < 1.0.1:
- The ASGI server passes
scope["path"] = "/protected"(from the HTTP request line — correct) and the rawHost: example.com/health?x=header through to Starlette. - Starlette constructs
request.urlby concatenating:[https://example.com/health?x=/protected.](https://example.com/health?x=/protected.) - Starlette re-parses that URL. After re-parsing,
request.url.pathis/health— not/protected. - The middleware checks
request.url.path. It sees/health. That's inEXEMPT_PATHS. It callscall_next(request)without authentication. - The actual routing uses
scope["path"]— which is still/protected. The request reaches the protected endpoint.
The middleware passed. The router routed correctly. The attacker is in.
The Hacker News community’s diagnosis of the bug class: “two parsers disagreeing and being too permissive in accepting input.” Each component works correctly. The combination is the vulnerability.
Who Is Affected
Every Python application meeting all three of these conditions:
- Uses
starlette < 1.0.1(directly or transitively through FastAPI, vLLM, LiteLLM, or any other Starlette-based framework) - Has authentication logic in a middleware that checks
request.url.pathorrequest.url - Runs on any ASGI server (Uvicorn, Hypercorn, Daphne, Granian)
The FastAPI-specific nuance matters and is often missed: standard FastAPI Depends() security is safe. FastAPI's built-in dependency injection system uses route matching, not request.url.path. If your authentication lives in Depends() or Security() decorators on your endpoints, you are not vulnerable through this path.
The risk is in custom BaseHTTPMiddleware or raw ASGI middleware that reads request.url.path for access control decisions. This pattern is common for exactly the use cases where it's most dangerous: protecting entire route prefixes, implementing allowlists and denylists, handling CSRF exemptions, rate limiting by path, payment gating.
Projects confirmed affected (with auth middleware using request.url.path): vLLM (where BadHost was originally discovered), LiteLLM, MCP server implementations, text generation inference servers, OpenAI shim proxies, AI agent harnesses, eval dashboards, and model management UIs.
MCP servers are specifically called out because the MCP specification requires unauthenticated OAuth discovery endpoints — path exemptions are essentially mandatory for MCP compliance. That design requirement creates a reliable exploitation path: the attacker doesn’t need to guess which paths are exempt. The spec told them.
Starlette’s download count: 325 million per week. The blast radius is not theoretical.
How to Check If You’re Vulnerable
The fast check: look for request.url.path in any middleware file.
# Scan your codebase
grep -rn "request\.url\.path\|request\.url" . \
--include="*.py" \
| grep -i "middleware\|auth\|exempt\|allow\|deny\|skip"
If you find matches in middleware, you are likely affected.
The static analysis check: the X41 Semgrep rules scan specifically for vulnerable request.url.path usage in Starlette middleware:
pip install semgrep
semgrep --config https://github.com/x41sec/poc/tree/master/starlette-host-header
The live check: against a running instance, you can verify whether a protected endpoint is reachable by injecting a known-exempt path into the Host header:
import socket
HOST = "your-service.example.com"
PORT = 80
# Target: /admin (should be protected)
# Injecting: /health (exempt path in most apps)
raw_request = (
"GET /admin HTTP/1.1\r\n"
f"Host: {HOST}/health?x=\r\n"
"Connection: close\r\n\r\n"
)
# Note: standard HTTP libraries normalise the Host header.
# Raw sockets are required to send the malformed header.
with socket.create_connection((HOST, PORT)) as s:
s.send(raw_request.encode())
response = b""
while chunk := s.recv(4096):
response += chunk
print(response.decode()[:500])
If you receive a 200 OK where you expected a 401 Unauthorized, you are vulnerable.
Important: only test systems you are authorised to test.
The Four Fixes
Fix 1: Update Starlette to 1.0.1 or Later (Do This First)
Starlette 1.0.1 validates the Host header against RFC 9112 and RFC 3986 before using it for URL construction. Invalid characters — including the /, ?, and # that make this attack work — cause the header to be rejected rather than incorporated into request.url. The vulnerability is fixed at the root.
pip install "starlette>=1.0.1"
# If using FastAPI:
pip install "fastapi[all]" # FastAPI 0.136.x+ pins Starlette >=1.0.1
Verify your dependency tree:
pip show starlette | grep Version
If you see anything before 1.0.1, update immediately.
Fix 2: Use scope["path"] Instead of request.url.path in Middleware
If you cannot update Starlette immediately, the mitigation is to read the path from the ASGI scope directly, rather than from the reconstructed URL. The ASGI scope path comes from the HTTP request line and cannot be manipulated via the Host header:
# VULNERABLE — do not use for auth decisions
class AuthMiddleware(BaseHTTPMiddleware):
EXEMPT_PATHS = ["/health", "/docs"]
async def dispatch(self, request: Request, call_next):
if request.url.path in self.EXEMPT_PATHS: # ← attacker controls this
return await call_next(request)
# ...
# SAFE — read from scope directly
class AuthMiddleware(BaseHTTPMiddleware):
EXEMPT_PATHS = ["/health", "/docs"]
async def dispatch(self, request: Request, call_next):
path = request.scope["path"] # ← cannot be manipulated via Host header
if path in self.EXEMPT_PATHS:
return await call_next(request)
# ...
This is a mitigating workaround, not a root fix. Apply it now and update Starlette as soon as possible.
Fix 3: Move Authentication to Endpoint-Level Dependencies (The Right Architecture)
The deeper lesson BadHost teaches is that path-based middleware authentication is architecturally fragile regardless of this specific bug. Deciding whether to authenticate based on what path was requested is fundamentally different from deciding whether to authenticate based on which endpoint was reached.
FastAPI’s Depends() and Security() decorators enforce authentication at the endpoint, not at a path pattern — which means they're immune to path manipulation attacks:
from fastapi import FastAPI, Depends, HTTPException, Security
from fastapi.security import HTTPBearer
app = FastAPI()
security = HTTPBearer()
def validate_token(credentials = Security(security)):
if not is_valid_token(credentials.credentials):
raise HTTPException(status_code=401, detail="Invalid token")
return credentials
# Protected endpoint — authentication enforced at the route, not the path
@app.get("/admin/data")
async def get_admin_data(token = Depends(validate_token)):
return {"data": "sensitive"}
# Public endpoint — no auth dependency
@app.get("/health")
async def health_check():
return {"status": "ok"}
No middleware. No path matching. No request.url.path. Each endpoint declares its own authentication requirements. Exempt paths are exempt because they have no auth dependency, not because a middleware decided to skip them.
Starlette’s requires() decorator provides the equivalent pattern for Starlette-native applications.
Fix 4: Deploy a Reverse Proxy in Front of Your ASGI Server
RFC-compliant reverse proxies — nginx, Caddy, Traefik, HAProxy — validate and normalise the Host header before forwarding requests to your application. A Host value containing /, ?, or # is rejected at the proxy layer before it ever reaches Starlette.
# nginx — validates Host header, rejects malformed values
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host; # Normalised by nginx, not raw client value
}
}
If you’re running Uvicorn or Hypercorn directly on a public port without a reverse proxy in front — common in development, staging, and self-hosted deployments — BadHost is exploitable. A reverse proxy neutralises it, regardless of your Starlette version.
Why Standard AI Tools Didn’t Catch It
The official BadHost site addresses directly why even Anthropic’s Claude Mythos — which found 10,000+ vulnerabilities through Project Glasswing — didn’t find this one:
BadHost is not a bug in one file or one repository. It spans three independent layers: ASGI servers pass the raw Host header, Starlette trusts it for URL construction, and middleware authors assume request.url.path is safe for authentication decisions. Each component behaves correctly in isolation. The vulnerability only emerges from the interaction between them, across specifications (HTTP, ASGI, Starlette, MCP).
Finding it required manual security research — understanding how these layers combine and building end-to-end exploit labs to confirm the attack. That’s a fundamentally different shape of work than pointing an agent at a single codebase. The Semgrep rules and CodeQL queries to detect vulnerable patterns were written after the bug was understood, not before.
The Immediate Actions
Today:
# 1. Check your Starlette version
pip show starlette
# 2. Update if below 1.0.1
pip install "starlette>=1.0.1"
# 3. Scan for vulnerable middleware patterns
grep -rn "request\.url\.path" . --include="*.py" | grep -v "test_\|#"
This week:
- Audit every middleware in your codebase that makes authentication decisions. Replace
request.url.pathwithrequest.scope["path"]as a stopgap. - Evaluate whether your authentication architecture should move to endpoint-level
Depends()rather than path-based middleware — this is the correct long-term position regardless of BadHost. - Verify that a reverse proxy sits in front of any publicly-exposed ASGI server. If not, add one.
If you maintain an open-source FastAPI or Starlette project:
Update your Starlette dependency pin to >=1.0.1 and cut a release. Your users inherit this vulnerability through you if you don't.
The Numbers That Put the Urgency in Context
Starlette has 325 million downloads per week. More than 400,000 repositories on GitHub depend on it. FastAPI, vLLM, LiteLLM, and the broader AI agent ecosystem are all downstream. The patch exists. Adoption is the only remaining variable.
The check is pip show starlette. The fix is pip install "starlette>=1.0.1". The cost is approximately thirty seconds.
That’s all that stands between your application and an authentication bypass that requires no credentials whatsoever.
Follow for more on Python security, API design, and the vulnerabilities that affect every developer in the ecosystem.
메타데이터
- post_id
- c168bf267aa2
- slug
- the-badhost-bug-every-fastapi-and-starlette-app-was-vulnerable-and-most-developers-dont-know-it-c168bf267aa2
- url
- https://medium.com/@yogeshkrishnanseeniraj/the-badhost-bug-every-fastapi-and-starlette-app-was-vulnerable-and-most-developers-dont-know-it-c168bf267aa2
- canonical_url
- https://medium.com/@yogeshkrishnanseeniraj/the-badhost-bug-every-fastapi-and-starlette-app-was-vulnerable-and-most-developers-dont-know-it-c168bf267aa2
- author_url
- https://medium.com/@yogeshkrishnanseeniraj
- status
- ok
- fetched_at
- 2026-08-10 09:09:27