Version in base suite: 0.46.1-3+deb13u2 Base version: starlette_0.46.1-3+deb13u2 Target version: starlette_0.46.1-3+deb13u3 Base file: /srv/ftp-master.debian.org/ftp/pool/main/s/starlette/starlette_0.46.1-3+deb13u2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/s/starlette/starlette_0.46.1-3+deb13u3.dsc changelog | 13 ++ patches/CVE-2026-48817.patch | 54 ++++++++++ patches/CVE-2026-54282.patch | 93 ++++++++++++++++++ patches/CVE-2026-54283.patch | 221 +++++++++++++++++++++++++++++++++++++++++++ patches/series | 3 5 files changed, 384 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpxqcvn6yh/starlette_0.46.1-3+deb13u2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpxqcvn6yh/starlette_0.46.1-3+deb13u3.dsc: no acceptable signature found diff -Nru starlette-0.46.1/debian/changelog starlette-0.46.1/debian/changelog --- starlette-0.46.1/debian/changelog 2026-05-25 15:26:48.000000000 +0000 +++ starlette-0.46.1/debian/changelog 2026-07-25 03:12:35.000000000 +0000 @@ -1,3 +1,16 @@ +starlette (0.46.1-3+deb13u3) trixie-security; urgency=medium + + * Team upload. + * d/patches: (Closes: #1140631, #1140632) + - CVE-2026-48817: Import and backport upstream patch + (Prevent unintended HTTPEndpoint method dispatch) + - CVE-2026-54282: Import upstream patch + (Validate request paths to prevent host confusion) + - CVE-2026-54283: Import and backport upstream patch + (Enforce max_fields and max_part_size limits) + + -- Matheus Polkorny Sat, 25 Jul 2026 00:12:35 -0300 + starlette (0.46.1-3+deb13u2) trixie-security; urgency=medium * CVE-2026-48710 (Closes: #1137375) diff -Nru starlette-0.46.1/debian/patches/CVE-2026-48817.patch starlette-0.46.1/debian/patches/CVE-2026-48817.patch --- starlette-0.46.1/debian/patches/CVE-2026-48817.patch 1970-01-01 00:00:00.000000000 +0000 +++ starlette-0.46.1/debian/patches/CVE-2026-48817.patch 2026-07-25 03:12:35.000000000 +0000 @@ -0,0 +1,54 @@ +From: Marcelo Trylesinski +Date: Sat, 23 May 2026 17:43:29 +0200 +Subject: Only dispatch standard HTTP verbs in `HTTPEndpoint` (#3286) + +--- + starlette/endpoints.py | 6 +++++- + tests/test_endpoints.py | 17 +++++++++++++++++ + 2 files changed, 22 insertions(+), 1 deletion(-) + +diff --git a/starlette/endpoints.py b/starlette/endpoints.py +index 1076902..ab4627f 100644 +--- a/starlette/endpoints.py ++++ b/starlette/endpoints.py +@@ -32,7 +32,11 @@ class HTTPEndpoint: + request = Request(self.scope, receive=self.receive) + handler_name = "get" if request.method == "HEAD" and not hasattr(self, "head") else request.method.lower() + +- handler: typing.Callable[[Request], typing.Any] = getattr(self, handler_name, self.method_not_allowed) ++ handler: Callable[[Request], Any] ++ if request.method in self._allowed_methods or (request.method == "HEAD" and "GET" in self._allowed_methods): ++ handler = getattr(self, handler_name) ++ else: ++ handler = self.method_not_allowed + is_async = is_async_callable(handler) + if is_async: + response = await handler(request) +diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py +index 7616387..dfc2019 100644 +--- a/tests/test_endpoints.py ++++ b/tests/test_endpoints.py +@@ -47,6 +47,23 @@ def test_http_endpoint_route_method(client: TestClient) -> None: + assert response.headers["allow"] == "GET" + + ++def test_http_endpoint_does_not_dispatch_non_verb_method(test_client_factory: TestClientFactory) -> None: ++ class Endpoint(HTTPEndpoint): ++ async def get(self, request: Request) -> PlainTextResponse: ++ return PlainTextResponse("Hello, world!") # pragma: no cover ++ ++ async def _do_delete(self, request: Request) -> PlainTextResponse: ++ return PlainTextResponse("Privileged helper") # pragma: no cover ++ ++ app = Router(routes=[Route("/", endpoint=Endpoint)]) ++ client = test_client_factory(app) ++ ++ response = client.request("_DO_DELETE", "/") ++ assert response.status_code == 405 ++ assert response.text == "Method Not Allowed" ++ assert response.headers["allow"] == "GET" ++ ++ + def test_websocket_endpoint_on_connect(test_client_factory: TestClientFactory) -> None: + class WebSocketApp(WebSocketEndpoint): + async def on_connect(self, websocket: WebSocket) -> None: diff -Nru starlette-0.46.1/debian/patches/CVE-2026-54282.patch starlette-0.46.1/debian/patches/CVE-2026-54282.patch --- starlette-0.46.1/debian/patches/CVE-2026-54282.patch 1970-01-01 00:00:00.000000000 +0000 +++ starlette-0.46.1/debian/patches/CVE-2026-54282.patch 2026-07-25 03:12:35.000000000 +0000 @@ -0,0 +1,93 @@ +From: Marcelo Trylesinski +Date: Thu, 11 Jun 2026 07:52:42 +0200 +Subject: 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(-) + +diff --git a/starlette/datastructures.py b/starlette/datastructures.py +index b1e3cea..6e9f812 100644 +--- a/starlette/datastructures.py ++++ b/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() +diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py +index 2e435c4..9cf9c17 100644 +--- a/tests/test_datastructures.py ++++ b/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_host(host: bytes) -> None: + 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 diff -Nru starlette-0.46.1/debian/patches/CVE-2026-54283.patch starlette-0.46.1/debian/patches/CVE-2026-54283.patch --- starlette-0.46.1/debian/patches/CVE-2026-54283.patch 1970-01-01 00:00:00.000000000 +0000 +++ starlette-0.46.1/debian/patches/CVE-2026-54283.patch 2026-07-25 03:12:35.000000000 +0000 @@ -0,0 +1,221 @@ +From: Marcelo Trylesinski +Date: Fri, 12 Jun 2026 11:03:48 +0200 +Subject: Enforce `max_fields` and `max_part_size` in `FormParser` (#3329) + +--- + starlette/formparsers.py | 19 ++++++- + starlette/requests.py | 14 +++++- + tests/test_formparsers.py | 124 ++++++++++++++++++++++++++++++++++++++++++++++ + 3 files changed, 154 insertions(+), 3 deletions(-) + +diff --git a/starlette/formparsers.py b/starlette/formparsers.py +index 4551d68..e6ed035 100644 +--- a/starlette/formparsers.py ++++ b/starlette/formparsers.py +@@ -54,10 +54,19 @@ class MultiPartException(Exception): + + + class FormParser: +- def __init__(self, headers: Headers, stream: typing.AsyncGenerator[bytes, None]) -> None: ++ def __init__( ++ self, ++ headers: Headers, ++ stream: AsyncGenerator[bytes, None], ++ *, ++ max_fields: int | float = 1000, ++ max_part_size: int = 1024 * 1024, # 1MB ++ ) -> None: + assert multipart is not None, "The `python-multipart` library must be installed to use form parsing." + self.headers = headers + self.stream = stream ++ self.max_fields = max_fields ++ self.max_part_size = max_part_size + self.messages: list[tuple[FormMessage, bytes]] = [] + + def on_field_start(self) -> None: +@@ -96,6 +105,7 @@ class FormParser: + field_value = b"" + + items: list[tuple[str, str | UploadFile]] = [] ++ field_count = 0 + + # Feed the parser with data from the request. + async for chunk in self.stream: +@@ -110,10 +120,17 @@ class FormParser: + field_name = b"" + field_value = b"" + elif message_type == FormMessage.FIELD_NAME: ++ if len(field_name) + len(field_value) + len(message_bytes) > self.max_part_size: ++ raise MultiPartException(f"Field exceeded maximum size of {int(self.max_part_size / 1024)}KB.") + field_name += message_bytes + elif message_type == FormMessage.FIELD_DATA: ++ if len(field_name) + len(field_value) + len(message_bytes) > self.max_part_size: ++ raise MultiPartException(f"Field exceeded maximum size of {int(self.max_part_size / 1024)}KB.") + field_value += message_bytes + elif message_type == FormMessage.FIELD_END: ++ field_count += 1 ++ if field_count > self.max_fields: ++ raise MultiPartException(f"Too many fields. Maximum number of fields is {self.max_fields}.") + name = unquote_plus(field_name.decode("latin-1")) + value = unquote_plus(field_value.decode("latin-1")) + items.append((name, value)) +diff --git a/starlette/requests.py b/starlette/requests.py +index 7dc04a7..c7fdc14 100644 +--- a/starlette/requests.py ++++ b/starlette/requests.py +@@ -278,8 +278,18 @@ class Request(HTTPConnection): + raise HTTPException(status_code=400, detail=exc.message) + raise exc + elif content_type == b"application/x-www-form-urlencoded": +- form_parser = FormParser(self.headers, self.stream()) +- self._form = await form_parser.parse() ++ try: ++ form_parser = FormParser( ++ self.headers, ++ self.stream(), ++ max_fields=max_fields, ++ max_part_size=max_part_size, ++ ) ++ self._form = await form_parser.parse() ++ except MultiPartException as exc: ++ if "app" in self.scope: ++ raise HTTPException(status_code=400, detail=exc.message) ++ raise exc + else: + self._form = FormData() + return self._form +diff --git a/tests/test_formparsers.py b/tests/test_formparsers.py +index 63577d6..6f55c59 100644 +--- a/tests/test_formparsers.py ++++ b/tests/test_formparsers.py +@@ -464,6 +464,130 @@ def test_multipart_multi_field_app_reads_body(tmpdir: Path, test_client_factory: + assert response.json() == {"some": "data", "second": "key pair"} + + ++@pytest.mark.parametrize( ++ "app,expectation", ++ [ ++ (app, pytest.raises(MultiPartException)), ++ (Starlette(routes=[Mount("/", app=app)]), does_not_raise()), ++ ], ++) ++def test_urlencoded_too_many_fields_raise( ++ app: ASGIApp, ++ expectation: AbstractContextManager[Exception], ++ test_client_factory: TestClientFactory, ++) -> None: ++ client = test_client_factory(app) ++ data = "&".join(f"N{i}=" for i in range(1001)) ++ with expectation: ++ res = client.post( ++ "/", ++ content=data, ++ headers={"Content-Type": "application/x-www-form-urlencoded"}, ++ ) ++ assert res.status_code == 400 ++ assert res.text == "Too many fields. Maximum number of fields is 1000." ++ ++ ++@pytest.mark.parametrize( ++ "app,expectation", ++ [ ++ (app, pytest.raises(MultiPartException)), ++ (Starlette(routes=[Mount("/", app=app)]), does_not_raise()), ++ ], ++) ++def test_urlencoded_field_exceeds_max_part_size_raise( ++ app: ASGIApp, ++ expectation: AbstractContextManager[Exception], ++ test_client_factory: TestClientFactory, ++) -> None: ++ client = test_client_factory(app) ++ data = "field=" + "x" * (1024 * 1024 + 1) ++ with expectation: ++ res = client.post( ++ "/", ++ content=data, ++ headers={"Content-Type": "application/x-www-form-urlencoded"}, ++ ) ++ assert res.status_code == 400 ++ assert res.text == "Field exceeded maximum size of 1024KB." ++ ++ ++@pytest.mark.parametrize( ++ "app,expectation", ++ [ ++ (app, pytest.raises(MultiPartException)), ++ (Starlette(routes=[Mount("/", app=app)]), does_not_raise()), ++ ], ++) ++def test_urlencoded_field_name_exceeds_max_part_size_raise( ++ app: ASGIApp, ++ expectation: AbstractContextManager[Exception], ++ test_client_factory: TestClientFactory, ++) -> None: ++ client = test_client_factory(app) ++ data = "x" * (1024 * 1024 + 1) + "=value" ++ with expectation: ++ res = client.post( ++ "/", ++ content=data, ++ headers={"Content-Type": "application/x-www-form-urlencoded"}, ++ ) ++ assert res.status_code == 400 ++ assert res.text == "Field exceeded maximum size of 1024KB." ++ ++ ++@pytest.mark.parametrize( ++ "app,expectation", ++ [ ++ (make_app_max_parts(max_fields=1), pytest.raises(MultiPartException)), ++ ( ++ Starlette(routes=[Mount("/", app=make_app_max_parts(max_fields=1))]), ++ does_not_raise(), ++ ), ++ ], ++) ++def test_urlencoded_max_fields_is_customizable( ++ app: ASGIApp, ++ expectation: AbstractContextManager[Exception], ++ test_client_factory: TestClientFactory, ++) -> None: ++ client = test_client_factory(app) ++ with expectation: ++ res = client.post( ++ "/", ++ content="a=1&b=2", ++ headers={"Content-Type": "application/x-www-form-urlencoded"}, ++ ) ++ assert res.status_code == 400 ++ assert res.text == "Too many fields. Maximum number of fields is 1." ++ ++ ++@pytest.mark.parametrize( ++ "app,expectation", ++ [ ++ (make_app_max_parts(max_part_size=1024 * 10), pytest.raises(MultiPartException)), ++ ( ++ Starlette(routes=[Mount("/", app=make_app_max_parts(max_part_size=1024 * 10))]), ++ does_not_raise(), ++ ), ++ ], ++) ++def test_urlencoded_max_part_size_is_customizable( ++ app: ASGIApp, ++ expectation: AbstractContextManager[Exception], ++ test_client_factory: TestClientFactory, ++) -> None: ++ client = test_client_factory(app) ++ with expectation: ++ res = client.post( ++ "/", ++ content="field=" + "x" * (1024 * 10 + 1), ++ headers={"Content-Type": "application/x-www-form-urlencoded"}, ++ ) ++ assert res.status_code == 400 ++ assert res.text == "Field exceeded maximum size of 10KB." ++ ++ + def test_user_safe_decode_helper() -> None: + result = _user_safe_decode(b"\xc4\x99\xc5\xbc\xc4\x87", "utf-8") + assert result == "ężć" diff -Nru starlette-0.46.1/debian/patches/series starlette-0.46.1/debian/patches/series --- starlette-0.46.1/debian/patches/series 2026-05-25 15:26:34.000000000 +0000 +++ starlette-0.46.1/debian/patches/series 2026-07-25 03:12:35.000000000 +0000 @@ -2,3 +2,6 @@ 0002-fix-cve-2024-28849-async-write.patch CVE-2025-62727.patch CVE-2026-48710.patch +CVE-2026-48817.patch +CVE-2026-54282.patch +CVE-2026-54283.patch