CISA added CVE-2026-48710, named BadHost, to the Known Exploited Vulnerabilities catalog on 2 September 2026 with a federal remediation date of 16 September. The GitHub advisory GHSA-86qp-5c8j-p5mr scores it 6.5 Moderate; X41 D-Sec, who reported it on 27 January 2026, rated it 7.0 High, and several vendor write-ups called it Critical. Any programme that patches on a CVSS threshold has an actively exploited bug sitting under its own trigger line, inside a transitive dependency almost nobody tracks by name. Starlette sits beneath FastAPI, vLLM, LiteLLM, MCP servers and FastMCP, Google ADK-Python, Ray Serve and BentoML, so it covers most of a Python HTTP estate at once, including the services where an agent's write access already depends on the HTTP layer being honest.
Short version: Starlette built request.url by concatenating the Host header with the request path and never validated the header, so Host: example.com/health?x= against GET /protected still routes to /protected while request.url.path reports /health. Middleware gating on that value allows the request, a failure shaped like admission control that fails open. Fixed in Starlette 1.0.1; the affected range is 0.8.3 through 1.0.0.
The reason this is still open in October is conflation. "We upgraded Starlette" gets reported as one status when it covers three exposures: the library version, the pattern in your own middleware, and a sibling CVE the headline fix never touched. These eleven checks separate them, and each produces an artifact you can hand to someone.
The 11 checks
- Ask the running process for its Starlette version, never the lockfile. Images get rebuilt from stale base layers and constraint files resolve differently per environment, so the authoritative answer lives inside the container that serves traffic.
kubectl exec deploy/api -- python -c \
"import starlette, fastapi; print(starlette.__version__, fastapi.__version__)"
Affected range is Starlette 0.8.3 through 1.0.0. The fix landed in 1.0.1 on 21 May 2026.
- Query your backlog by KEV membership, not by severity. A CVSS 7.0 gate never surfaced this one. Join your SBOM inventory against the KEV feed directly and make that join the standing rule for exploited-in-the-wild findings.
curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
| jq -r '.vulnerabilities[] | select(.cveID=="CVE-2026-48710") | "\(.dateAdded) \(.dueDate)"'
- Stop waiting for FastAPI to set the floor. FastAPI 0.135.1 declares
starlette>=0.46.0with no upper bound and no security floor. In fastapi/fastapi discussion #15193 a collaborator declined to raise it, citing users blocked by third-party constraints, and pointed people at pip-audit and Dependabot; the thread is still unanswered. Set the floor where the resolver cannot ignore it.
# constraints.txt
starlette>=1.3.0
Install with pip install -c constraints.txt -r requirements.txt. 1.3.0 is the minimum that clears both CVEs in this article; see check 11 for why 1.7.x is the better landing spot.
- Price the upgrade before you promise a date. FastAPI added Starlette 1.x support in 0.133.0, released 24 February 2026. Services below that cannot reach a patched Starlette without a framework bump, which is a code change and a regression cycle per service, with the interesting breakage arriving after CI already goes green. Count them before you commit to anything.
grep -rn "fastapi==" --include="*.txt" --include="*.lock" . | sort -u
- Reproduce it against your own staging, and read the verdict carefully. The X41 proof of concept is one line, and a 200 where a 401 or 403 belongs is a confirmed bypass.
curl -i -H 'Host: foo?' http://staging.internal:8000/admin
curl -i -H 'Host: 127.0.0.1:8000/health?x=' http://127.0.0.1:8000/admin
A 422 from a FastAPI app is not a clean result. Request validation runs after the security gate, so a 422 means the gate was already passed.
- Grep for the pattern, because the upgrade does not fix your code. Starlette 1.0.1 stops the header poisoning
request.url; it does nothing about path-based authorisation in middleware. Any security decision readingrequest.url.pathorstr(request.url)should readrequest.scope["path"], or move into FastAPI'sDepends()andSecurity(), which are route-scoped.
rg -n "request\.url\.path|str\(request\.url\)" --glob '!tests/*' .
The badhost.org toolkit publishes Semgrep rules and CodeQL queries for sweeping many repositories at once. The underlying mistake is familiar from RAG chunks that carry no ACL: authorisation attached to a label near the resource, not to the resource.
- Patch the sibling CVE, or your remediation report is wrong. CVE-2026-54282 (GHSA-jp82-jpqv-5vv3, published 11 June 2026, CVSS 3.7) runs the same trick in the opposite direction. A request path with no leading slash crosses into the URL authority, so
GET @google.comwithHost: localhostreconstructs ashttp://localhost@google.comandrequest.url.hostnamebecomes attacker-controlled. It was fixed in 1.3.0, not 1.0.1. Test it with a raw socket, because curl normalises the path.
printf 'GET @google.com HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc 127.0.0.1 8000
- Read the case rule before leaning on TrustedHostMiddleware. Host matching in Starlette routing and
TrustedHostMiddlewarewas case-sensitive against RFC 9110 §4.2.3, soHost: EXAMPLE.comdrew a 400 from the middleware and a 404 from aHost("example.org")route. Starlette PR #3542 addresses that and was still open at the time of writing.
rg -n "TrustedHostMiddleware|allowed_hosts" .
allowed_hosts=["*"] shows up often in this grep. It makes the middleware inert as a control, so count those services as unprotected.
- Validate
Hostat the edge, then test the inner hop too. nginx validates theHostheader and answers 400 on invalid values, so a scan from the internet can report REJECTED while the service itself is wide open. The BadHost scanner testsX-Forwarded-Hostas a second carrier for the same payload. Run the two curls from check 5 inside the mesh, against the service address, not through the ingress. - Fix the log before you try to hunt this. uvicorn's default access formatter is
%(client_addr)s - "%(request_line)s" %(status_code)s. The request line carries the path, so theHostheader never reaches app logs and there is nothing to search retroactively. Add it at the proxy where you already keep retention:$http_hostin an nginxlog_format, or%REQ(HOST)%in an Envoy access log. Then alert onHostvalues containing/,?,#,@, a backslash or a space, the six shapes Starlette's own regression test covers. - Expect your absolute URLs to move, and assert on them. The 1.0.1 fix added
_HOST_RE = re.compile(r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9.:]+\])(?::[0-9]+)?$", re.IGNORECASE)tostarlette/datastructures.pyin PR #3279. Anything failing that match silently falls back toscope["server"], the bind socket. RFC 3986reg-namepermits underscores, percent-encoded octets and sub-delims, so an internal hostname likemy_servicecounted as malformed andrequest.url.netloccame back as0.0.0.0:8000. Current releases moved the logic intoparse_host_header()instarlette/_utils.pywith a much wider grammar, one reason to land on 1.7.x and not 1.0.1. Before the upgrade, pin a test that assertsurl_for()still returns your external origin, so a redirect or a signed callback URL cannot quietly change shape.
FAQ
Does upgrading to Starlette 1.0.1 fully fix CVE-2026-48710? It fixes the header poisoning and nothing more. The sibling CVE-2026-54282 needs 1.3.0, and your own middleware pattern needs check 6.
Is FastAPI affected? Indirectly, and it will not pin you out of trouble: 0.135.1 declares starlette>=0.46.0 with no floor, and apps on FastAPI below 0.133.0 cannot install Starlette 1.x at all.
Why is a KEV-listed bug only 6.5? The scorers disagreed (6.5 from GitHub, 7.0 from X41, Critical from some vendors). KEV membership is the signal that matters here, not the number.
What proves the bypass is real? A 200 on a protected route with a poisoned Host. A 422 means validation rejected the body after the gate let the request through.
Wrap-up
The habit worth keeping is check 2: join your dependency inventory to the KEV feed and let exploited-in-the-wild trigger patching on its own, with no reference to the CVSS number. Horizon3 validated chaining CVE-2026-48710 with LiteLLM's CVE-2026-42271 on 1 June 2026, turning an authenticated command injection into unauthenticated RCE at CVSS 10.0, patched in LiteLLM 1.83.7 and 1.84.0. A 6.5 was the first link in that chain.
Eleven checks is a day for one service and a quarter of coordination across an estate of forty, where the expensive parts are the FastAPI bumps in check 4, the middleware audit in check 6, and producing one defensible statement of coverage over runtimes nobody has inventoried. If that inventory and the CI gate behind it are what you actually need delivered, that is the point where it stops being an afternoon and becomes an engagement.
Comments
Be the first to comment.