Description
AllowedHostsMiddleware used to strip the port from the Host header before matching it against the allowed-hosts regex. As of 2.22.0 it matches the full, un-stripped header, so a request with Host: localhost:8000 no longer matches an allowed host of localhost and is rejected with 400 - invalid host header.
The host check works correctly in production behind a proxy (where Host is the bare domain with no port), which is likely why this has gone unnoticed — it only manifests when the Host header carries a port.
Root cause
The port-strip was lost in c4189610 ("fix: fix typing after merge"), a follow-up to the intentional x-forwarded-host removal. The walrus now binds host to the un-stripped header value; host.split(":")[0] survives only as the truthiness guard on the left of the and, while fullmatch(host) receives the value with the port still attached:
# before (2.21.x) — port stripped, then matched
if host := headers.get("host").split(":")[0]:
if self.allowed_hosts_regex.fullmatch(host):
# after (2.22.0+) — `host` keeps the port; the strip only gates the `if`
if (host := headers.get("host")) is not None and host.split(":")[0]:
if self.allowed_hosts_regex.fullmatch(host): # matches "localhost:8000"
Suggested fix
Match against the port-stripped value while keeping the None guard, e.g.:
if (raw_host := headers.get("host")) is not None and (host := raw_host.split(":")[0]):
if self.allowed_hosts_regex.fullmatch(host):
URL to code causing the issue
No response
MCVE
from litestar import Litestar, get
from litestar.config.allowed_hosts import AllowedHostsConfig
from litestar.testing import TestClient
@get("/")
async def handler() -> str:
return "ok"
app = Litestar(
route_handlers=[handler],
allowed_hosts=AllowedHostsConfig(allowed_hosts=["localhost"]),
)
def test_host_with_port_is_allowed() -> None:
# base_url drives the Host header -> "localhost:8000"
with TestClient(app=app, base_url="http://localhost:8000") as client:
response = client.get("/")
assert response.status_code == 200 # passes on 2.21.x, fails (400) on >= 2.22.0
Steps to reproduce
Expected behavior
Host: localhost:8000 matches the allowed host localhost (port stripped before matching), and the request returns 200. This is the 2.21.x behavior, and matches the convention used by Django's ALLOWED_HOSTS and Starlette's TrustedHostMiddleware.
Actual behavior
The request is rejected with 400 - {"message":"invalid host header"} because the port is included in the matched value.
Screenshots
No response
Logs
Litestar Version
2.22.0 – 2.24.0 (still present on main)
Platform
Description
AllowedHostsMiddlewareused to strip the port from theHostheader before matching it against the allowed-hosts regex. As of 2.22.0 it matches the full, un-stripped header, so a request withHost: localhost:8000no longer matches an allowed host oflocalhostand is rejected with400 - invalid host header.The host check works correctly in production behind a proxy (where
Hostis the bare domain with no port), which is likely why this has gone unnoticed — it only manifests when theHostheader carries a port.Root cause
The port-strip was lost in
c4189610("fix: fix typing after merge"), a follow-up to the intentionalx-forwarded-hostremoval. The walrus now bindshostto the un-stripped header value;host.split(":")[0]survives only as the truthiness guard on the left of theand, whilefullmatch(host)receives the value with the port still attached:Suggested fix
Match against the port-stripped value while keeping the
Noneguard, e.g.:URL to code causing the issue
No response
MCVE
Steps to reproduce
Expected behavior
Host: localhost:8000matches the allowed hostlocalhost(port stripped before matching), and the request returns200. This is the 2.21.x behavior, and matches the convention used by Django'sALLOWED_HOSTSand Starlette'sTrustedHostMiddleware.Actual behavior
The request is rejected with
400 - {"message":"invalid host header"}because the port is included in the matched value.Screenshots
No response
Logs
Litestar Version
2.22.0 – 2.24.0 (still present on
main)Platform