From 764dab0dcfb9033d75442d7a359645c9f94648c6 Mon Sep 17 00:00:00 2001
From: Marcelo Trylesinski <marcelotryle@gmail.com>
Date: Thu, 21 May 2026 18:49:37 +0200
Subject: [PATCH] Ignore malformed `Host` header when constructing
 `request.url` (#3279)

---
 starlette/datastructures.py  | 14 ++++++--------
 tests/test_datastructures.py | 26 ++++++++++++++++++++++++++
 2 files changed, 32 insertions(+), 8 deletions(-)

Index: starlette-0.41.3/starlette/datastructures.py
===================================================================
--- starlette-0.41.3.orig/starlette/datastructures.py
+++ starlette-0.41.3/starlette/datastructures.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import typing
+import re
 from shlex import shlex
 from urllib.parse import SplitResult, parse_qsl, urlencode, urlsplit
 
@@ -19,6 +20,9 @@ _KeyType = typing.TypeVar("_KeyType")
 # that is, you can't do `Mapping[str, Animal]()["fido"] = Dog()`
 _CovariantValueType = typing.TypeVar("_CovariantValueType", covariant=True)
 
+# Rejects Host header chars (/, ?, #, @, ...) that would let urlsplit produce a path differing from scope["path"].
+_HOST_RE = re.compile(r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9.:]+\])(?::[0-9]+)?$", re.IGNORECASE)
+
 
 class URL:
     def __init__(
@@ -41,7 +45,7 @@ class URL:
                     host_header = value.decode("latin-1")
                     break
 
-            if host_header is not None:
+            if host_header is not None and _HOST_RE.fullmatch(host_header):
                 url = f"{scheme}://{host_header}{path}"
             elif server is None:
                 url = path
Index: starlette-0.41.3/tests/test_datastructures.py
===================================================================
--- starlette-0.41.3.orig/tests/test_datastructures.py
+++ starlette-0.41.3/tests/test_datastructures.py
@@ -159,6 +159,32 @@ def test_url_from_scope() -> None:
     assert repr(u) == "URL('http://example.com:8000/some/path?query=string')"
 
 
+@pytest.mark.parametrize(
+    "host",
+    [
+        pytest.param(b"foo/?x=", id="question-mark"),
+        pytest.param(b"foo/#", id="hash"),
+        pytest.param(b"foo/bar", id="slash"),
+        pytest.param(b"user@foo", id="at-sign"),
+        pytest.param(b"foo\\bar", id="backslash"),
+        pytest.param(b"foo bar", id="space"),
+    ],
+)
+def test_url_from_scope_with_invalid_host(host: bytes) -> None:
+    """An invalid Host header should be ignored, falling back to the server tuple."""
+    u = URL(
+        scope={
+            "scheme": "http",
+            "server": ("example.com", 80),
+            "path": "/admin",
+            "query_string": b"",
+            "headers": [(b"host", host)],
+        }
+    )
+    assert u.path == "/admin"
+    assert u.netloc == "example.com"
+
+
 def test_headers() -> None:
     h = Headers(raw=[(b"a", b"123"), (b"a", b"456"), (b"b", b"789")])
     assert "a" in h
