From 167b5850e809f38b27fbfed62d58bf6442855975 Mon Sep 17 00:00:00 2001
From: Marcelo Trylesinski <marcelotryle@gmail.com>
Date: Thu, 11 Jun 2026 07:52:42 +0200
Subject: [PATCH] Build `request.url` from structured components (#3326)

Co-authored-by: nic-lovin <10554285+nic-lovin@users.noreply.github.com>
---
 starlette/datastructures.py  | 20 ++++++++++----------
 tests/test_datastructures.py | 31 +++++++++++++++++++++++++++++++
 2 files changed, 41 insertions(+), 10 deletions(-)

Index: starlette-0.41.3/starlette/datastructures.py
===================================================================
--- starlette-0.41.3.orig/starlette/datastructures.py
+++ starlette-0.41.3/starlette/datastructures.py
@@ -46,19 +46,19 @@ class URL:
                     break
 
             if host_header is not None and _HOST_RE.fullmatch(host_header):
-                url = f"{scheme}://{host_header}{path}"
-            elif server is None:
-                url = path
-            else:
+                netloc = host_header
+            elif server is not None:
                 host, port = server
                 default_port = {"http": 80, "https": 443, "ws": 80, "wss": 443}[scheme]
-                if port == default_port:
-                    url = f"{scheme}://{host}{path}"
-                else:
-                    url = f"{scheme}://{host}:{port}{path}"
+                netloc = host if port == default_port else f"{host}:{port}"
+            else:
+                netloc = None
 
-            if query_string:
-                url += "?" + query_string.decode()
+            query = query_string.decode()
+            if netloc is not None:
+                url = SplitResult(scheme=scheme, netloc=netloc, path=path, query=query, fragment="").geturl()
+            else:
+                url = f"{path}?{query}" if query else path
         elif components:
             assert not url, 'Cannot set both "url" and "**components".'
             url = URL("").replace(**components).components.geturl()
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
@@ -119,6 +119,10 @@ def test_url_from_scope() -> None:
     assert u == "/path/to/somewhere?abc=123"
     assert repr(u) == "URL('/path/to/somewhere?abc=123')"
 
+    u = URL(scope={"path": "/path/to/somewhere", "query_string": b"", "headers": []})
+    assert u == "/path/to/somewhere"
+    assert repr(u) == "URL('/path/to/somewhere')"
+
     u = URL(
         scope={
             "scheme": "https",
@@ -185,6 +189,33 @@ def test_url_from_scope_with_invalid_hos
     assert u.netloc == "example.com"
 
 
+@pytest.mark.parametrize(
+    "path, expected_path",
+    [
+        pytest.param("@google.com", "/@google.com", id="at-sign"),
+        pytest.param("user:pass@google.com", "/user:pass@google.com", id="userinfo"),
+        pytest.param("//google.com/x", "//google.com/x", id="scheme-relative"),
+        pytest.param("http://google.com/x", "/http://google.com/x", id="absolute"),
+    ],
+)
+@pytest.mark.parametrize("with_host_header", [True, False], ids=["host-header", "server-fallback"])
+def test_url_from_scope_with_authority_in_path(path: str, expected_path: str, with_host_header: bool) -> None:
+    """A path must not bleed into the authority."""
+    headers = [(b"host", b"localhost")] if with_host_header else []
+    u = URL(
+        scope={
+            "scheme": "http",
+            "server": ("localhost", 80),
+            "path": path,
+            "query_string": b"a=b",
+            "headers": headers,
+        }
+    )
+    assert u.hostname == "localhost"
+    assert u.path == expected_path
+    assert u.query == "a=b"
+
+
 def test_headers() -> None:
     h = Headers(raw=[(b"a", b"123"), (b"a", b"456"), (b"b", b"789")])
     assert "a" in h
