Version in base suite: 0.26.1-1 Base version: starlette_0.26.1-1 Target version: starlette_0.26.1-1+deb12u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/s/starlette/starlette_0.26.1-1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/s/starlette/starlette_0.26.1-1+deb12u1.dsc changelog | 16 ++++ gbp.conf | 2 patches/CVE-2023-29159.patch | 91 ++++++++++++++++++++++ patches/CVE-2024-47874.patch | 111 +++++++++++++++++++++++++++ patches/CVE-2025-54121.patch | 172 +++++++++++++++++++++++++++++++++++++++++++ patches/CVE-2026-48710.patch | 85 +++++++++++++++++++++ patches/series | 4 + 7 files changed, 480 insertions(+), 1 deletion(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmppmj0zgay/starlette_0.26.1-1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmppmj0zgay/starlette_0.26.1-1+deb12u1.dsc: no acceptable signature found diff -Nru starlette-0.26.1/debian/changelog starlette-0.26.1/debian/changelog --- starlette-0.26.1/debian/changelog 2023-04-01 13:33:33.000000000 +0000 +++ starlette-0.26.1/debian/changelog 2026-05-22 16:26:42.000000000 +0000 @@ -1,3 +1,19 @@ +starlette (0.26.1-1+deb12u1) bookworm-security; urgency=medium + + * Team upload. + * d/gbp.conf: Update to Bookworm + * d/patches: (Closes: #1085295, #1109805, #1137375) + - CVE-2023-29159: Import upstream patch + (directory traversal vulnerability in StaticFiles) + - CVE-2024-47874: Import and backport upstream patch + (DoS via unlimited multipart/form-data field buffering) + - CVE-2025-54121: Import and backport upstream patch + (event loop blocking on large multipart uploads to disk) + - CVE-2026-48710: Import and backport upstream patch + (Ignore malformed Host when constructing request.url) + + -- Matheus Polkorny Fri, 22 May 2026 13:26:42 -0300 + starlette (0.26.1-1) unstable; urgency=medium * New upstream release diff -Nru starlette-0.26.1/debian/gbp.conf starlette-0.26.1/debian/gbp.conf --- starlette-0.26.1/debian/gbp.conf 2022-12-13 12:19:08.000000000 +0000 +++ starlette-0.26.1/debian/gbp.conf 2026-05-22 16:26:42.000000000 +0000 @@ -1,3 +1,3 @@ [DEFAULT] pristine-tar = True -debian-branch = debian/master +debian-branch = debian/bookworm diff -Nru starlette-0.26.1/debian/patches/CVE-2023-29159.patch starlette-0.26.1/debian/patches/CVE-2023-29159.patch --- starlette-0.26.1/debian/patches/CVE-2023-29159.patch 1970-01-01 00:00:00.000000000 +0000 +++ starlette-0.26.1/debian/patches/CVE-2023-29159.patch 2026-05-22 16:26:42.000000000 +0000 @@ -0,0 +1,91 @@ +From: Amin Alaee +Date: Tue, 16 May 2023 14:03:57 +0330 +Subject: Merge pull request from GHSA-v5gw-mw7f-84px + +Origin: upstream, https://github.com/Kludex/starlette/commit/1797de464124b090f10cf570441e8292936d63e3 +--- + starlette/staticfiles.py | 2 +- + tests/test_staticfiles.py | 42 +++++++++++++++++++++++++++++++++++++----- + 2 files changed, 38 insertions(+), 6 deletions(-) + +diff --git a/starlette/staticfiles.py b/starlette/staticfiles.py +index 4d075b3..4c85606 100644 +--- a/starlette/staticfiles.py ++++ b/starlette/staticfiles.py +@@ -169,7 +169,7 @@ class StaticFiles: + else: + full_path = os.path.realpath(joined_path) + directory = os.path.realpath(directory) +- if os.path.commonprefix([full_path, directory]) != directory: ++ if os.path.commonpath([full_path, directory]) != directory: + # Don't allow misbehaving clients to break out of the static files + # directory. + continue +diff --git a/tests/test_staticfiles.py b/tests/test_staticfiles.py +index eb6c73f..23d0b57 100644 +--- a/tests/test_staticfiles.py ++++ b/tests/test_staticfiles.py +@@ -1,8 +1,8 @@ + import os +-import pathlib + import stat + import tempfile + import time ++from pathlib import Path + + import anyio + import pytest +@@ -28,13 +28,12 @@ def test_staticfiles(tmpdir, test_client_factory): + assert response.text == "" + + +-def test_staticfiles_with_pathlib(tmpdir, test_client_factory): +- base_dir = pathlib.Path(tmpdir) +- path = base_dir / "example.txt" ++def test_staticfiles_with_pathlib(tmp_path: Path, test_client_factory): ++ path = tmp_path / "example.txt" + with open(path, "w") as file: + file.write("") + +- app = StaticFiles(directory=base_dir) ++ app = StaticFiles(directory=tmp_path) + client = test_client_factory(app) + response = client.get("/example.txt") + assert response.status_code == 200 +@@ -516,3 +515,36 @@ def test_staticfiles_disallows_path_traversal_with_symlinks(tmpdir): + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Not Found" ++ ++ ++def test_staticfiles_avoids_path_traversal(tmp_path: Path): ++ statics_path = tmp_path / "static" ++ statics_disallow_path = tmp_path / "static_disallow" ++ ++ statics_path.mkdir() ++ statics_disallow_path.mkdir() ++ ++ static_index_file = statics_path / "index.html" ++ statics_disallow_path_index_file = statics_disallow_path / "index.html" ++ static_file = tmp_path / "static1.txt" ++ ++ static_index_file.write_text("

Hello

") ++ statics_disallow_path_index_file.write_text("

Private

") ++ static_file.write_text("Private") ++ ++ app = StaticFiles(directory=statics_path) ++ ++ # We can't test this with 'httpx', so we test the app directly here. ++ path = app.get_path({"path": "/../static1.txt"}) ++ with pytest.raises(HTTPException) as exc_info: ++ anyio.run(app.get_response, path, {"method": "GET"}) ++ ++ assert exc_info.value.status_code == 404 ++ assert exc_info.value.detail == "Not Found" ++ ++ path = app.get_path({"path": "/../static_disallow/index.html"}) ++ with pytest.raises(HTTPException) as exc_info: ++ anyio.run(app.get_response, path, {"method": "GET"}) ++ ++ assert exc_info.value.status_code == 404 ++ assert exc_info.value.detail == "Not Found" diff -Nru starlette-0.26.1/debian/patches/CVE-2024-47874.patch starlette-0.26.1/debian/patches/CVE-2024-47874.patch --- starlette-0.26.1/debian/patches/CVE-2024-47874.patch 1970-01-01 00:00:00.000000000 +0000 +++ starlette-0.26.1/debian/patches/CVE-2024-47874.patch 2026-05-22 16:26:42.000000000 +0000 @@ -0,0 +1,111 @@ +From: Marcelo Trylesinski +Date: Tue, 15 Oct 2024 08:40:51 +0200 +Subject: Merge commit from fork + +Origin: upstream, https://github.com/Kludex/starlette/commit/fd038f3070c302bff17ef7d173dbb0b007617733 + +Backported by: Matheus Polkorny + +Changes: +- Refresh Patch context +- Update hunks' offsets +- Add missing ASGI typing imports required by the upstream test +- Drop TestClientFactory type annotations not present in bookworm +--- + starlette/formparsers.py | 11 +++++++---- + tests/test_formparsers.py | 38 ++++++++++++++++++++++++++++++++++++++ + 2 files changed, 45 insertions(+), 4 deletions(-) + +diff --git a/starlette/formparsers.py b/starlette/formparsers.py +index 8ab5d66..059141d 100644 +--- a/starlette/formparsers.py ++++ b/starlette/formparsers.py +@@ -26,12 +26,12 @@ class FormMessage(Enum): + class MultipartPart: + content_disposition: typing.Optional[bytes] = None + field_name: str = "" +- data: bytes = b"" ++ data: bytearray = field(default_factory=bytearray) + file: typing.Optional[UploadFile] = None + item_headers: typing.List[typing.Tuple[bytes, bytes]] = field(default_factory=list) + + +-def _user_safe_decode(src: bytes, codec: str) -> str: ++def _user_safe_decode(src: bytes | bytearray, codec: str) -> str: + try: + return src.decode(codec) + except (UnicodeDecodeError, LookupError): +@@ -116,7 +116,8 @@ class FormParser: + + + class MultiPartParser: +- max_file_size = 1024 * 1024 ++ max_file_size = 1024 * 1024 # 1MB ++ max_part_size = 1024 * 1024 # 1MB + + def __init__( + self, +@@ -150,7 +151,9 @@ class MultiPartParser: + def on_part_data(self, data: bytes, start: int, end: int) -> None: + message_bytes = data[start:end] + if self._current_part.file is None: +- self._current_part.data += message_bytes ++ if len(self._current_part.data) + len(message_bytes) > self.max_part_size: ++ raise MultiPartException(f"Part exceeded maximum size of {int(self.max_part_size / 1024)}KB.") ++ self._current_part.data.extend(message_bytes) + else: + self._file_parts_to_write.append((self._current_part, message_bytes)) + +diff --git a/tests/test_formparsers.py b/tests/test_formparsers.py +index 502f780..eac7483 100644 +--- a/tests/test_formparsers.py ++++ b/tests/test_formparsers.py +@@ -9,6 +9,7 @@ from starlette.formparsers import MultiPartException, UploadFile, _user_safe_dec + from starlette.requests import Request + from starlette.responses import JSONResponse + from starlette.routing import Mount ++from starlette.types import ASGIApp, Receive, Scope, Send + + + class ForceMultipartDict(dict): +@@ -682,3 +683,40 @@ def test_max_fields_is_customizable_high(test_client_factory): + "content": "", + "content_type": None, + } ++ ++ ++@pytest.mark.parametrize( ++ "app,expectation", ++ [ ++ (app, pytest.raises(MultiPartException)), ++ (Starlette(routes=[Mount("/", app=app)]), does_not_raise()), ++ ], ++) ++def test_max_part_size_exceeds_limit( ++ app: ASGIApp, ++ expectation: typing.ContextManager[Exception], ++ test_client_factory, ++ ): ++ client = test_client_factory(app) ++ boundary = "------------------------4K1ON9fZkj9uCUmqLHRbbR" ++ ++ multipart_data = ( ++ f"--{boundary}\r\n" ++ f'Content-Disposition: form-data; name="small"\r\n\r\n' ++ "small content\r\n" ++ f"--{boundary}\r\n" ++ f'Content-Disposition: form-data; name="large"\r\n\r\n' ++ + ("x" * 1024 * 1024 + "x") # 1MB + 1 byte of data ++ + "\r\n" ++ f"--{boundary}--\r\n" ++ ).encode("utf-8") ++ ++ headers = { ++ "Content-Type": f"multipart/form-data; boundary={boundary}", ++ "Transfer-Encoding": "chunked", ++ } ++ ++ with expectation: ++ response = client.post("/", data=multipart_data, headers=headers) # type: ignore ++ assert response.status_code == 400 ++ assert response.text == "Part exceeded maximum size of 1024KB." diff -Nru starlette-0.26.1/debian/patches/CVE-2025-54121.patch starlette-0.26.1/debian/patches/CVE-2025-54121.patch --- starlette-0.26.1/debian/patches/CVE-2025-54121.patch 1970-01-01 00:00:00.000000000 +0000 +++ starlette-0.26.1/debian/patches/CVE-2025-54121.patch 2026-05-22 16:26:42.000000000 +0000 @@ -0,0 +1,172 @@ +From: Michael Honaker <37811263+HonakerM@users.noreply.github.com> +Date: Mon, 21 Jul 2025 02:24:02 +0900 +Subject: Make UploadFile check for future rollover (#2962) + +Co-authored-by: Marcelo Trylesinski + +Origin: upstream, https://github.com/Kludex/starlette/commit/9f7ec2eb512fcc3fe90b43cb9dd9e1d08696bec1 + +Backported by: Matheus Polkorny + +Changes: +- Refresh Patch context +- Update hunks' offsets +- Add spool_max_size attribute required by the upstream tests +- Drop TestClientFactory type annotations not present in bookworm +--- + starlette/datastructures.py | 22 ++++++++++++--- + starlette/formparsers.py | 1 + + tests/test_formparsers.py | 66 ++++++++++++++++++++++++++++++++++++++++++++- + 3 files changed, 84 insertions(+), 5 deletions(-) + +diff --git a/starlette/datastructures.py b/starlette/datastructures.py +index 236f9fa..c4a349c 100644 +--- a/starlette/datastructures.py ++++ b/starlette/datastructures.py +@@ -447,6 +447,10 @@ class UploadFile: + self.size = size + self.headers = headers or Headers() + ++ # Capture max size from SpooledTemporaryFile if one is provided. This slightly speeds up future checks. ++ # Note 0 means unlimited mirroring SpooledTemporaryFile's __init__ ++ self._max_mem_size = getattr(self.file, "_max_size", 0) ++ + @property + def content_type(self) -> typing.Optional[str]: + return self.headers.get("content-type", None) +@@ -457,14 +461,24 @@ class UploadFile: + rolled_to_disk = getattr(self.file, "_rolled", True) + return not rolled_to_disk + ++ def _will_roll(self, size_to_add: int) -> bool: ++ # If we're not in_memory then we will always roll ++ if not self._in_memory: ++ return True ++ ++ # Check for SpooledTemporaryFile._max_size ++ future_size = self.file.tell() + size_to_add ++ return bool(future_size > self._max_mem_size) if self._max_mem_size else False ++ + async def write(self, data: bytes) -> None: ++ new_data_len = len(data) + if self.size is not None: +- self.size += len(data) ++ self.size += new_data_len + +- if self._in_memory: +- self.file.write(data) +- else: ++ if self._will_roll(new_data_len): + await run_in_threadpool(self.file.write, data) ++ else: ++ self.file.write(data) + + async def read(self, size: int = -1) -> bytes: + if self._in_memory: +diff --git a/starlette/formparsers.py b/starlette/formparsers.py +index 059141d..ce8c385 100644 +--- a/starlette/formparsers.py ++++ b/starlette/formparsers.py +@@ -116,6 +116,7 @@ class FormParser: + + + class MultiPartParser: ++ spool_max_size = 1024 * 1024 # 1MB + max_file_size = 1024 * 1024 # 1MB + max_part_size = 1024 * 1024 # 1MB + +diff --git a/tests/test_formparsers.py b/tests/test_formparsers.py +index eac7483..b3382e4 100644 +--- a/tests/test_formparsers.py ++++ b/tests/test_formparsers.py +@@ -1,11 +1,18 @@ + import os + import typing ++import threading ++from collections.abc import Generator + from contextlib import nullcontext as does_not_raise ++from io import BytesIO ++from pathlib import Path ++from tempfile import SpooledTemporaryFile ++from typing import Any, ClassVar ++from unittest import mock + + import pytest + + from starlette.applications import Starlette +-from starlette.formparsers import MultiPartException, UploadFile, _user_safe_decode ++from starlette.formparsers import MultiPartException, MultiPartParser, UploadFile, _user_safe_decode + from starlette.requests import Request + from starlette.responses import JSONResponse + from starlette.routing import Mount +@@ -99,6 +106,22 @@ async def app_read_body(scope, receive, send): + await response(scope, receive, send) + + ++async def app_monitor_thread(scope: Scope, receive: Receive, send: Send) -> None: ++ """Helper app to monitor what thread the app was called on. ++ ++ This can later be used to validate thread/event loop operations. ++ """ ++ request = Request(scope, receive) ++ ++ # Make sure we parse the form ++ await request.form() ++ await request.close() ++ ++ # Send back the current thread id ++ response = JSONResponse({"thread_ident": threading.current_thread().ident}) ++ await response(scope, receive, send) ++ ++ + def make_app_max_parts(max_files: int = 1000, max_fields: int = 1000): + async def app(scope, receive, send): + request = Request(scope, receive) +@@ -304,6 +327,47 @@ def test_multipart_request_mixed_files_and_data(tmpdir, test_client_factory): + } + + ++class ThreadTrackingSpooledTemporaryFile(SpooledTemporaryFile[bytes]): ++ """Helper class to track which threads performed the rollover operation. ++ ++ This is not threadsafe/multi-test safe. ++ """ ++ ++ rollover_threads: ClassVar[set[int | None]] = set() ++ ++ def rollover(self) -> None: ++ ThreadTrackingSpooledTemporaryFile.rollover_threads.add(threading.current_thread().ident) ++ super().rollover() ++ ++ ++@pytest.fixture ++def mock_spooled_temporary_file() -> Generator[None]: ++ try: ++ with mock.patch("starlette.formparsers.SpooledTemporaryFile", ThreadTrackingSpooledTemporaryFile): ++ yield ++ finally: ++ ThreadTrackingSpooledTemporaryFile.rollover_threads.clear() ++ ++ ++def test_multipart_request_large_file_rollover_in_background_thread( ++ mock_spooled_temporary_file: None, test_client_factory, ++): ++ """Test that Spooled file rollovers happen in background threads.""" ++ data = BytesIO(b" " * (MultiPartParser.spool_max_size + 1)) ++ ++ client = test_client_factory(app_monitor_thread) ++ response = client.post("/", files=[("test_large", data)]) ++ assert response.status_code == 200 ++ ++ # Parse the event thread id from the API response and ensure we have one ++ app_thread_ident = response.json().get("thread_ident") ++ assert app_thread_ident is not None ++ ++ # Ensure the app thread was not the same as the rollover one and that a rollover thread exists ++ assert app_thread_ident not in ThreadTrackingSpooledTemporaryFile.rollover_threads ++ assert len(ThreadTrackingSpooledTemporaryFile.rollover_threads) == 1 ++ ++ + def test_multipart_request_with_charset_for_filename(tmpdir, test_client_factory): + client = test_client_factory(app) + response = client.post( diff -Nru starlette-0.26.1/debian/patches/CVE-2026-48710.patch starlette-0.26.1/debian/patches/CVE-2026-48710.patch --- starlette-0.26.1/debian/patches/CVE-2026-48710.patch 1970-01-01 00:00:00.000000000 +0000 +++ starlette-0.26.1/debian/patches/CVE-2026-48710.patch 2026-05-22 16:26:42.000000000 +0000 @@ -0,0 +1,85 @@ +From: Marcelo Trylesinski +Date: Thu, 21 May 2026 18:49:37 +0200 +Subject: Ignore malformed `Host` header when constructing `request.url` + (#3279) + +Origin: upstream, https://github.com/Kludex/starlette/commit/764dab0dcfb9033d75442d7a359645c9f94648c6 + +Backported by: Matheus Polkorny + +Changes: +- Refresh Patch context +- Update hunks' offsets +--- + starlette/datastructures.py | 6 +++++- + tests/test_datastructures.py | 26 ++++++++++++++++++++++++++ + 2 files changed, 31 insertions(+), 1 deletion(-) + +diff --git a/starlette/datastructures.py b/starlette/datastructures.py +index c4a349c..8d44b1c 100644 +--- a/starlette/datastructures.py ++++ b/starlette/datastructures.py +@@ -1,6 +1,7 @@ + import typing + from collections.abc import Sequence + from shlex import shlex ++import re + from urllib.parse import SplitResult, parse_qsl, urlencode, urlsplit + + from starlette.concurrency import run_in_threadpool +@@ -18,6 +19,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__( +@@ -40,7 +44,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 +diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py +index b6e24ff..5f8bef3 100644 +--- a/tests/test_datastructures.py ++++ b/tests/test_datastructures.py +@@ -139,6 +139,32 @@ def test_url_from_scope(): + assert repr(u) == "URL('https://example.org/path/to/somewhere?abc=123')" + + ++@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(): + h = Headers(raw=[(b"a", b"123"), (b"a", b"456"), (b"b", b"789")]) + assert "a" in h diff -Nru starlette-0.26.1/debian/patches/series starlette-0.26.1/debian/patches/series --- starlette-0.26.1/debian/patches/series 1970-01-01 00:00:00.000000000 +0000 +++ starlette-0.26.1/debian/patches/series 2026-05-22 16:26:42.000000000 +0000 @@ -0,0 +1,4 @@ +CVE-2023-29159.patch +CVE-2024-47874.patch +CVE-2025-54121.patch +CVE-2026-48710.patch