BadHost in My Own Rack: Auditing a Self-Hosted AI Stack for the Starlette Host-Header Bypass
I audited my self-hosted AI stack against CVE-2026-48710 (BadHost), the Starlette host-header auth bypass. One component ran an affected version and stayed protected by code, not by patch. The exact per-container checks, and the scan that exposed it.
Executive Summary
In May 2026 a flaw landed in Starlette, the ASGI library that sits under FastAPI and, by extension, under most of the self-hosted AI-inference stack. The bug is tracked as CVE-2026-48710, nicknamed BadHost. The short version: one character in the HTTP Host header can shift where the server thinks the request was aimed, so an unauthenticated client can reach a protected endpoint while the authentication check looks at a different path. Starlette 1.0.1 fixed it. Uptake has been slow, the discoverers re-disclosed in late May, and Persistent Security's 50,000-host internet scan (published under the Nemesis scanner) found roughly 2,393 vulnerable deployments still answering across 58 countries, with 79% of them still vulnerable days later.
I audited my own homelab the same way the public write-ups suggest, using pip show per container, the X41 scanner against my own endpoints, and a closed-loop proof of concept on a throwaway app. The result was not what my earlier internal audit claimed. My most-used component, the LiteLLM proxy, runs starlette 0.50.0, which is inside the affected range. It is protected, but by code, not by version. This note documents what I found, how I verified it, and the exact commands to run the same check against your own stack.
Why it matters for a self-hosted AI stack
Starlette is the substrate. FastAPI is built on it, and FastAPI is what LiteLLM's proxy, TGI, most OpenAI-compatible shims, and many MCP gateways run on. vLLM's HTTP server is also Starlette underneath. That means a flaw in URL reconstruction is not a single application bug; it is a shared primitive that every one of those servers inherits.
The discoverers found it during a source audit of vLLM. The patch shipped in 1.0.1; in the discoverers' account it went out without a score anyone could use to prioritize. Three months on, the concern is less the flaw than the patch rate, because so many self-hosted inference and MCP endpoints are direct ASGI exposure with no edge in front of them. MCP servers are the highest-exposure class, because the spec mandates unauthenticated OAuth discovery endpoints, which gives an attacker a reachable, unauthenticated surface to probe.
The flaw and the fix
The affected versions are starlette 0.8.3 through 1.0.0 (the CPE range is [0.8.3, 1.0.1)). The fix is 1.0.1.
In affected versions, request.url is rebuilt by concatenating scheme://host + path and re-parsing the result. The Host header value is not validated against the host grammar before that concatenation. If the Host carries a character that changes where the path boundary falls (a /, a ?, a #), the re-parsed request.url.path no longer matches the path the router actually used. The router dispatches on the real scope["path"], so the protected endpoint still executes, but any middleware that read request.url.path saw the attacker's path instead.
A gate that says "require auth if the path starts with /admin" will let a request through when request.url.path is no longer /admin/..., even though the handler that runs is the /admin one.
The 1.0.1 fix validates the Host against RFC 9112 section 3.2 and RFC 3986 section 3.2.2, and falls back to scope["server"] when the value is malformed. The practical, pattern-level fix that works on any version is to make authorization decisions from scope["path"] (or from route-level dependencies) rather than from request.url.path.
The homelab audit
I ran the audit the way you should: per environment, because a single pip show on the host says nothing about what a container or a separate venv actually runs.
| Environment | Where | starlette | In affected range? |
|---|---|---|---|
| LiteLLM proxy | litellm_proxy container |
0.50.0 | Yes |
| vLLM (R9700) | qwen38-vllm container, /opt/vllm venv |
1.3.1 | No |
| Ghost MCP server | Node process (@fanyangmeng/ghost-mcp) |
n/a | Not Python |
| hermes-agent venv | MCP SDK transport | 1.3.1 | No |
| docker-worker (uv) | MCP tooling | 1.6.0 | No |
The one that surprised me is the LiteLLM starlette version. My earlier internal audit reported 0.50.0 and wrote that it was far newer than 1.0.1. That is backwards. 0.50.0 is a 2025-11 release on the 0.x line; 1.0.1 is the 2026-05 patch. In PEP 440, 0.50.0 < 1.0.1. The container is inside the affected range, and I confirmed it by running the code rather than by reading a version string.
The reason it is not currently exploitable is the auth pattern, not the version. Inside the container I found no BaseHTTPMiddleware subclass in the LiteLLM code at all. Authentication is FastAPI dependency injection (user_api_key_auth per endpoint), and the custom middleware is pure ASGI that reads scope["path"]. That is the pattern Red Hat's advisory calls out as safe, and it is exactly the pattern that makes this flaw inert here. I would not be comfortable betting the homelab on a version string alone, which is why the next two sections exist.
A note on the other two Python environments: vLLM runs 1.3.1 and has no HTTP-level auth to begin with (inference endpoints are internal-only), so there is no gate to bypass. hermes-agent and docker-worker run 1.3.1 and 1.6.0, both past the patch. The Ghost MCP server is Node.js, so it is not Starlette-relevant.
Scanner run against my own proxy
I used the X41 Python scanner from the x41sec/poc repo, a stdlib-only script that runs two tiers: a denylist probe that injects a fixed harmless path into the Host header to catch fail-open middleware, and an allowlist probe that discovers unauthenticated paths to catch fail-closed middleware. Exit code 1 means vulnerable; --json is for CI. (Nemesis also runs an online scanner at mcp-scan.nemesis.services if you want a remote cross-check.)
My reverse proxy is the hermes-wiki nginx container on 127.0.0.1:18080. It serves a static page and has no protected endpoints, so the scanner correctly reports no-protected-endpoint and there is nothing to bypass. The real target is the LiteLLM proxy on 127.0.0.1:4000, the Starlette app with actual protected paths.
$ python3 scan_cve_2026_48710.py http://127.0.0.1:4000 \
--mode generic --protected /health --protected /v1/models --protected /key/info
Verifying protected endpoints
[+] /health -> 401 (protected)
[+] /v1/models -> 401 (protected)
[+] /key/info -> 401 (protected)
Testing denylist bypass (Tier 1)
Testing allowlist bypass (Tier 2)
[+] NOT VULNERABLE - no bypasses found
It tested 144 combinations across the two tiers and found no bypass. The scope["path"] auth is doing its job. One thing the scan did flag: /openapi.json and /redoc respond 200 with no auth, so the API schema is readable by anyone who can reach the proxy. That is a separate exposure, not a BadHost bypass, but the OpenAPI document listing every endpoint is not something I want unauthenticated, and I am closing it.
Safe proof of concept, closed-loop
I wanted to see the flaw for myself, so I wrote a throwaway FastAPI app that uses the vulnerable pattern, ran it on loopback only, inside the LiteLLM container, with dummy data and no real secret behind the protected route. Nothing was exposed, and the PoC lives in a file you can re-run against your own container if you want the same confirmation.
The vulnerable gate:
class PathGate(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
if request.url.path.startswith("/admin"): # the vulnerable pattern
if not request.headers.get("authorization"):
return JSONResponse({"detail": "Not authenticated"}, status_code=403)
return await call_next(request)
Two things, run against that app on the container's starlette 0.50.0:
First, the URL reconstruction. Building a request.url from a scope where the real path is /admin/key/info:
Host: 127.0.0.1:18099 -> url.path = '/admin/key/info'
Host: foo -> url.path = '/admin/key/info'
Host: foo? -> url.path = '' (path collapsed)
The ? in the Host moves the path/query boundary, so url.path becomes empty while the router still dispatches to /admin/key/info.
Second, the gate behavior on the live loopback app:
GET /admin/key/info -> 403 (denied)
GET /admin/key/info + Authorization -> 200 (allowed)
GET /admin/key/info Host: foo -> 403 (still denied)
GET /admin/key/info Host: foo? -> 200 (bypassed, no auth)
That last line is the bug, demonstrated on the exact vulnerable version my proxy runs, against a dummy endpoint. The same app would have returned 403 on starlette 1.0.1 or later. This is a detection and self-audit artifact; it is not a working exploit against a real target, and I am not publishing one.
The dependency-audit command set
This is the whole check, end to end. Run it against every container and venv that could be serving Starlette.
pip show per environment, to get the version that actually matters:
docker exec litellm_proxy sh -c '/app/.venv/bin/python -c "import starlette;print(starlette.__version__)"'
docker exec qwen38-vllm /opt/vllm/bin/python -c "import starlette;print(starlette.__version__)"
The auth-pattern check, which is what actually decides exploitability on a given version:
docker exec litellm_proxy sh -c 'grep -rn "BaseHTTPMiddleware" /app/.venv/lib/python3.13/site-packages/litellm/'
docker exec litellm_proxy sh -c 'grep -rn "url.path" /app/.venv/lib/python3.13/site-packages/litellm/proxy/auth/'
pip-audit, which flags the advisory directly. I ran it in a venv pinned to the container's exact version:
pip install "starlette==0.50.0" # then:
pip-audit
That returned seven starlette advisory rows covering five distinct advisories, including PYSEC-2026-161 (BadHost) with fix version 1.0.1, plus four others fixed only in later releases (1.1.0, 1.3.0, 1.3.1). The same run pinned to 1.3.1 returned zero. If your freeze file carries GPU-specific wheels, audit in a clean venv pinned to the starlette version rather than the whole requirements file.
Semgrep with the X41 taint rules, applied to the proxy source to find any code path that feeds request.url.path into an authorization decision:
semgrep scan --config semgrep_x41.yml litellm/proxy
On my 424-file proxy source tree that produced 29 findings. The meaningful cluster is in auth/auth_utils.py, and it is the safe one: the function prefers scope["path"] and only falls back to request.url.path on an error path, which the taint rule cannot see past. The rest are in SCIM, pass-through, and management endpoints, where request.url feeds logging, response metadata, and route naming rather than an access-control decision. The scanner output is a triage list, not a verdict, so read the hit before you panic.
The score dispute
There is an open disagreement about how bad this is, and it is worth framing precisely because the wrong number is what most of the coverage got out.
NVD and the Starlette vendor rate it 6.5, Medium, under CVSS 3.1, scoring the library-level primitive: a path-string mismatch that only bites code that re-derives authorization from request.url.path. The discoverers disagree. X41 D-Sec scored it 7.0 under CVSS 4.0, with high impact to system confidentiality and integrity, deliberately scoring the downstream impact rather than the primitive, and they describe real-world impact as "critical," arguing the official rating "severely understates" what happens when the flaw lands in LLM gateways, MCP servers, and admin panels. Note that "critical" is a qualitative label, not a CVSS number.
As of this writing NVD has not re-scored, so the official number remains 6.5 Medium, and the dispute is open. It is as much about scoring frameworks (3.1 versus 4.0) and scope (library versus downstream) as it is about the number itself.
The maintainer adds a third position worth carrying: in his framing the bug comes from the application pattern and the deployment (path-based auth middleware, no Host validation at the edge, direct ASGI exposure), never from something Starlette intended, and Starlette 1.0.1 closes the class at the framework level anyway. I think the maintainer is right that the pattern is the real story, and the discoverers are right that the pattern is everywhere in the self-hosted AI world. Both are true, and both should drive the fix.
What I am doing about it
- Patch the LiteLLM proxy: upgrade starlette to at least 1.0.1, in practice 1.3.1 or newer, and re-pin the image. The current starlette release is 1.6.0. I verified that the installed LiteLLM release (1.87.1) does not pin starlette at all, while LiteLLM's current main does pin
starlette>=1.0.1,<2.0. I am moving the container onto a build that carries the pin. - Close the
/openapi.jsonand/redocexposure on the proxy. The schema should not be readable unauthenticated. - Keep vLLM internal-only. It has no HTTP auth, which is fine while it is loopback-bound and wrong the moment it is reachable.
- Put the audit command set on a monthly cron across every container, because the failure mode here is not a bad version on day one; it is a version that nobody re-checked.
If your stack looks like mine, the version will not save you the way I expected it to. Check the version, then check the pattern, then scan the live endpoint. Do all three.
Sources: badhost.org; OSTIF disclosure (2026-05-26); X41-2026-002; NVD CVE-2026-48710; GHSA-86qp-5c8j-p5mr; PyPI starlette release data; Nemesis/Persistent Security "Bad Hosts in the Wild"; x41sec/poc scanner, Semgrep rules, and CodeQL queries. All scanner, PoC, and dependency-audit results in this note were produced on this host on 2026-08-23. Companion artifacts: badhost_poc_selfaudit.py (the closed-loop PoC), evidence-log.md (raw command outputs).