TL;DR

CVE-2026-48710 ("BadHost") — Starlette < 1.0.1 builds request.url from the raw HTTP Host header without validation. A single character (/, ?, or #) in Host shifts the path/query/fragment boundaries when the URL is re-parsed, so middleware that authorizes on request.url.path sees a different path than the ASGI router actually dispatched. Any auth middleware reading request.url.path fails open. Starlette ships ~325M downloads/week and sits under FastAPI, vLLM, LiteLLM, Text Generation Inference, OpenAI-compatible proxies, the Python MCP SDK, and most AI-agent dashboards — one bug, one character, an enormous blast radius. X41 D-Sec found it during an OSTIF-sponsored vLLM audit; coordinated disclosure 2026-05-22; patched in Starlette 1.0.1 (2026-05-21).

What happened

Starlette reconstructs request.url by concatenating the HTTP Host header with the request path and re-parsing the result. The Host value is not validated against RFC 9112 § 3.2 / RFC 3986 § 3.2.2 before reconstruction. A malicious client sends a Host header that contains an HTTP-meaningful character — /, ?, or # — and the re-parser splits the URL in a different place than the ASGI server did.

Result: request.url.path returns one string, request.scope["path"] (the actual routed path) returns another. Auth middleware that decides on request.url.path runs its check against the wrong value. The router still dispatches to the protected endpoint.

The minimal PoC published by X41:

GET /admin HTTP/1.1
Host: foo?

…returns 200 OK against a server whose middleware blocks /admin via request.url.path. One character. No credentials.

The bug was found by JJ, Yassine El Baaj, and Markus Vervier at X41 D-Sec during a sponsored source-code audit of vLLM for OSTIF.org funded by the Alpha-Omega Project. Independent reports from ehhthing and Nicolas Lamoureux corroborated. Coordinated disclosure was published 2026-05-22 (badhost.org, OSTIF, X41 advisory X41-2026-002, Starlette GHSA-86qp-5c8j-p5mr), one day after the upstream fix shipped — so operators had effectively zero lead time before the technique was public.

Why this is bigger than one CVE

Starlette is the ASGI foundation under most modern Python AI infrastructure:

Tool What it does Why it inherits BadHost
FastAPI The dominant Python web/AI API framework Built directly on Starlette; any Depends() auth on path is exposed
vLLM Open-source LLM inference server Public /generate etc. behind path-based auth
LiteLLM OpenAI-compatible proxy → 100+ models Admin endpoints, master-key gated
Text Generation Inference Hugging Face's inference server Same
MCP Python SDK / FastMCP Server-side MCP Path-based tool dispatch
OpenAI-compatible proxies LM Studio, Ollama wrappers, custom shims Same
Agent harnesses / eval dashboards LangChain Server, OpenHands, etc. Anything using request.url.path for routing/auth

Starlette has ~325 M weekly PyPI downloads and 400 K+ dependent GitHub projects — possibly the broadest-surface Python security event of 2026.

"Two parsers, one string" — a recurring class

BadHost is the third recent disclosure where two parts of one stack disagree on how to interpret a string, and the security check runs on the wrong interpretation. Sibling shapes already in this repo:

  • Argv-smugglingClaude Code eagerParseCliFlag deeplink RCE: the pre-parser and the main argv parser disagree on which token is a flag value.
  • Allowlist vs resolverClaude Code SOCKS5 null-byte bypass: the matcher sees attacker.com\x00.google.com, the OS truncates at \x00 and dials attacker.com.
  • Auth-middleware vs ASGI router (BadHost, this advisory): middleware reads request.url.path (rebuilt from Host), router reads scope["path"] (raw ASGI path), they diverge.

The general lesson: never base a security decision on a reconstructed value when a canonical value is available right next to it. In Starlette, the canonical value is request.scope["path"]. In a CLI, it's the post-parse argv structure. In a network allowlist, it's the bytes the OS will actually pass to connect().

Am I affected?

# 1) Is Starlette < 1.0.1 anywhere in your deps?
pip show starlette 2>/dev/null | grep -E '^(Name|Version):'
pip list 2>/dev/null | grep -i starlette

# Locked versions, all envs:
grep -RniE 'starlette[<=>!~ ]+[0-9]' requirements*.txt pyproject.toml poetry.lock pdm.lock uv.lock 2>/dev/null

# 2) Does any middleware in your code make security decisions from
#    request.url, request.url.path, or str(request.url)?
#    Each match is a candidate gap.
grep -RnE 'request\.url(\.path)?|str\(request\.url\)' . 2>/dev/null

# 3) Live probe (against your OWN server) — does it serve a protected path
#    when Host is "foo?" ?
curl -sk -H 'Host: foo?' -o /dev/null -w '%{http_code}\n' https://your-host/admin
# 200 → vulnerable. 4xx/5xx → likely fine.

# Or use the official scanner:
#   https://badhost.org/  (X41 + Persistent Security Industries + Bintech)

IOCs / identifiers

Type Value
CVE CVE-2026-48710
GHSA GHSA-86qp-5c8j-p5mr
PYSEC PYSEC-2026-161
X41 advisory X41-2026-002
Affected starlette >= 0.8.3, <= 1.0.0
Fixed starlette 1.0.1 (released 2026-05-21)
Disclosed 2026-05-22
Trigger payload Host: foo? (or any /, ?, # in Host)
Researcher group JJ, Yassine El Baaj, Markus Vervier (X41 D-Sec) — OSTIF / Alpha-Omega vLLM audit
Official CVSS 6.5 (Moderate). X41 calls it critical given downstream blast radius.

If you are affected

  1. Upgrade Starlette to ≥ 1.0.1 in every environment that runs FastAPI / vLLM / LiteLLM / MCP server / any ASGI app: bash pip install --upgrade 'starlette>=1.0.1' # then, in each downstream: pip install --upgrade fastapi vllm litellm 'mcp>=0' text-generation Pin the floor in requirements.txt / pyproject.toml.

  2. In middleware, replace request.url.path with request.scope["path"] anywhere a security decision is made. The scope path comes directly from the ASGI server, not from a reconstructed URL, and it's the value the router actually dispatched. This is the structural fix; the Starlette patch is defense-in-depth on top of it.

  3. Treat any pre-patch deployment as potentially probed. The scanner at badhost.org was public from disclosure day; assume external scans hit you. Review access logs for unusual Host headers (Host: containing /, ?, #, or other URI-unsafe characters) across 2026-05-21 → your patch date.

  4. Rotate the credentials that lived behind the path-auth-gated endpoints if you cannot rule out unauthorized access — particularly LLM API keys, MCP server tokens, admin/master keys on LiteLLM, model-management UIs, and any secrets retrievable from now-exposed admin pages.

  5. Audit downstream MCP servers separately. A Python MCP server using FastAPI/Starlette and serving over HTTP is reachable from any caller that can hit its Host; the canonical local-bind defense (the historical "MCP runs on stdio") does not apply once you've exposed an HTTP transport.

Prevention

prevention/agent-sandboxing.mdprevention/credential-hygiene.mdDon't authorize on reconstructed strings. When the framework hands you both a canonical and a derived value, the canonical one is the security boundary. In Starlette: request.scope["path"], not request.url.path. In any auth allowlist: the post-canonicalization form. → Validate the Host header at the edge. Put a reverse proxy or ASGI middleware that enforces RFC-grammar Host values before your auth middleware runs. → MCP-over-HTTP needs the same network posture as any other public API — auth on every endpoint, allowlists by IP, not "MCP is local-only." (See advisories/2026-05-mcp-stdio-systemic-rce.md for the broader systemic MCP-exposure class.)

Sources