Control: tags 1104056 + patch Control: tags 1104056 + pending Dear maintainer,
I've prepared an NMU for python-h11 (versioned as 0.14.0-1.1) and uploaded it to DELAYED/5. Please feel free to tell me if I should cancel it. cu Adrian
diffstat for python-h11-0.14.0 python-h11-0.14.0 changelog | 8 patches/0001-Validate-Chunked-Encoding-chunk-footer.patch | 169 ++++++++++++++ patches/series | 1 3 files changed, 178 insertions(+) diff -Nru python-h11-0.14.0/debian/changelog python-h11-0.14.0/debian/changelog --- python-h11-0.14.0/debian/changelog 2023-01-09 15:00:57.000000000 +0200 +++ python-h11-0.14.0/debian/changelog 2025-04-25 18:48:39.000000000 +0300 @@ -1,3 +1,11 @@ +python-h11 (0.14.0-1.1) unstable; urgency=medium + + * Non-maintainer upload. + * CVE-2025-43859: Don't accept malformed chunked-encoding bodies + (Closes: #1104056) + + -- Adrian Bunk <b...@debian.org> Fri, 25 Apr 2025 18:48:39 +0300 + python-h11 (0.14.0-1) unstable; urgency=low * New upstream release. diff -Nru python-h11-0.14.0/debian/patches/0001-Validate-Chunked-Encoding-chunk-footer.patch python-h11-0.14.0/debian/patches/0001-Validate-Chunked-Encoding-chunk-footer.patch --- python-h11-0.14.0/debian/patches/0001-Validate-Chunked-Encoding-chunk-footer.patch 1970-01-01 02:00:00.000000000 +0200 +++ python-h11-0.14.0/debian/patches/0001-Validate-Chunked-Encoding-chunk-footer.patch 2025-04-25 18:47:21.000000000 +0300 @@ -0,0 +1,169 @@ +From 8b97933b259f34e5c66a4a1ae46c6fc176e26999 Mon Sep 17 00:00:00 2001 +From: "Nathaniel J. Smith" <n...@anthropic.com> +Date: Thu, 9 Jan 2025 23:41:42 -0800 +Subject: Validate Chunked-Encoding chunk footer + +Also add a bit more thoroughness to some tests that I noticed while I +was working on it. + +Thanks to Jeppe Bonde Weikop for the report. +--- + h11/_readers.py | 23 +++++++++++-------- + h11/tests/test_io.py | 54 +++++++++++++++++++++++++++++++------------- + 2 files changed, 51 insertions(+), 26 deletions(-) + +diff --git a/h11/_readers.py b/h11/_readers.py +index 08a9574..1348565 100644 +--- a/h11/_readers.py ++++ b/h11/_readers.py +@@ -148,10 +148,9 @@ chunk_header_re = re.compile(chunk_header.encode("ascii")) + class ChunkedReader: + def __init__(self) -> None: + self._bytes_in_chunk = 0 +- # After reading a chunk, we have to throw away the trailing \r\n; if +- # this is >0 then we discard that many bytes before resuming regular +- # de-chunkification. +- self._bytes_to_discard = 0 ++ # After reading a chunk, we have to throw away the trailing \r\n. ++ # This tracks the bytes that we need to match and throw away. ++ self._bytes_to_discard = b"" + self._reading_trailer = False + + def __call__(self, buf: ReceiveBuffer) -> Union[Data, EndOfMessage, None]: +@@ -160,15 +159,19 @@ class ChunkedReader: + if lines is None: + return None + return EndOfMessage(headers=list(_decode_header_lines(lines))) +- if self._bytes_to_discard > 0: +- data = buf.maybe_extract_at_most(self._bytes_to_discard) ++ if self._bytes_to_discard: ++ data = buf.maybe_extract_at_most(len(self._bytes_to_discard)) + if data is None: + return None +- self._bytes_to_discard -= len(data) +- if self._bytes_to_discard > 0: ++ if data != self._bytes_to_discard[:len(data)]: ++ raise LocalProtocolError( ++ f"malformed chunk footer: {data!r} (expected {self._bytes_to_discard!r})" ++ ) ++ self._bytes_to_discard = self._bytes_to_discard[len(data):] ++ if self._bytes_to_discard: + return None + # else, fall through and read some more +- assert self._bytes_to_discard == 0 ++ assert self._bytes_to_discard == b"" + if self._bytes_in_chunk == 0: + # We need to refill our chunk count + chunk_header = buf.maybe_extract_next_line() +@@ -194,7 +197,7 @@ class ChunkedReader: + return None + self._bytes_in_chunk -= len(data) + if self._bytes_in_chunk == 0: +- self._bytes_to_discard = 2 ++ self._bytes_to_discard = b"\r\n" + chunk_end = True + else: + chunk_end = False +diff --git a/h11/tests/test_io.py b/h11/tests/test_io.py +index 2b47c0e..634c49d 100644 +--- a/h11/tests/test_io.py ++++ b/h11/tests/test_io.py +@@ -360,22 +360,34 @@ def _run_reader(*args: Any) -> List[Event]: + return normalize_data_events(events) + + +-def t_body_reader(thunk: Any, data: bytes, expected: Any, do_eof: bool = False) -> None: ++def t_body_reader(thunk: Any, data: bytes, expected: list, do_eof: bool = False) -> None: + # Simple: consume whole thing + print("Test 1") + buf = makebuf(data) +- assert _run_reader(thunk(), buf, do_eof) == expected ++ try: ++ assert _run_reader(thunk(), buf, do_eof) == expected ++ except LocalProtocolError: ++ if LocalProtocolError in expected: ++ pass ++ else: ++ raise + + # Incrementally growing buffer + print("Test 2") + reader = thunk() + buf = ReceiveBuffer() + events = [] +- for i in range(len(data)): +- events += _run_reader(reader, buf, False) +- buf += data[i : i + 1] +- events += _run_reader(reader, buf, do_eof) +- assert normalize_data_events(events) == expected ++ try: ++ for i in range(len(data)): ++ events += _run_reader(reader, buf, False) ++ buf += data[i : i + 1] ++ events += _run_reader(reader, buf, do_eof) ++ assert normalize_data_events(events) == expected ++ except LocalProtocolError: ++ if LocalProtocolError in expected: ++ pass ++ else: ++ raise + + is_complete = any(type(event) is EndOfMessage for event in expected) + if is_complete and not do_eof: +@@ -436,14 +448,12 @@ def test_ChunkedReader() -> None: + ) + + # refuses arbitrarily long chunk integers +- with pytest.raises(LocalProtocolError): +- # Technically this is legal HTTP/1.1, but we refuse to process chunk +- # sizes that don't fit into 20 characters of hex +- t_body_reader(ChunkedReader, b"9" * 100 + b"\r\nxxx", [Data(data=b"xxx")]) ++ # Technically this is legal HTTP/1.1, but we refuse to process chunk ++ # sizes that don't fit into 20 characters of hex ++ t_body_reader(ChunkedReader, b"9" * 100 + b"\r\nxxx", [LocalProtocolError]) + + # refuses garbage in the chunk count +- with pytest.raises(LocalProtocolError): +- t_body_reader(ChunkedReader, b"10\x00\r\nxxx", None) ++ t_body_reader(ChunkedReader, b"10\x00\r\nxxx", [LocalProtocolError]) + + # handles (and discards) "chunk extensions" omg wtf + t_body_reader( +@@ -457,10 +467,22 @@ def test_ChunkedReader() -> None: + + t_body_reader( + ChunkedReader, +- b"5 \r\n01234\r\n" + b"0\r\n\r\n", ++ b"5 \t \r\n01234\r\n" + b"0\r\n\r\n", + [Data(data=b"01234"), EndOfMessage()], + ) + ++ # Chunked encoding with bad chunk termination characters are refused. Originally we ++ # simply dropped the 2 bytes after a chunk, instead of validating that the bytes ++ # were \r\n -- so we would successfully decode the data below as b"xxxa". And ++ # apparently there are other HTTP processors that ignore the chunk length and just ++ # keep reading until they see \r\n, so they would decode it as b"xxx__1a". Any time ++ # two HTTP processors accept the same input but interpret it differently, there's a ++ # possibility of request smuggling shenanigans. So we now reject this. ++ t_body_reader(ChunkedReader, b"3\r\nxxx__1a\r\n", [LocalProtocolError]) ++ ++ # Confirm we check both bytes individually ++ t_body_reader(ChunkedReader, b"3\r\nxxx\r_1a\r\n", [LocalProtocolError]) ++ t_body_reader(ChunkedReader, b"3\r\nxxx_\n1a\r\n", [LocalProtocolError]) + + def test_ContentLengthWriter() -> None: + w = ContentLengthWriter(5) +@@ -483,8 +505,8 @@ def test_ContentLengthWriter() -> None: + dowrite(w, EndOfMessage()) + + w = ContentLengthWriter(5) +- dowrite(w, Data(data=b"123")) == b"123" +- dowrite(w, Data(data=b"45")) == b"45" ++ assert dowrite(w, Data(data=b"123")) == b"123" ++ assert dowrite(w, Data(data=b"45")) == b"45" + with pytest.raises(LocalProtocolError): + dowrite(w, EndOfMessage(headers=[("Etag", "asdf")])) + +-- +2.30.2 + diff -Nru python-h11-0.14.0/debian/patches/series python-h11-0.14.0/debian/patches/series --- python-h11-0.14.0/debian/patches/series 1970-01-01 02:00:00.000000000 +0200 +++ python-h11-0.14.0/debian/patches/series 2025-04-25 18:48:39.000000000 +0300 @@ -0,0 +1 @@ +0001-Validate-Chunked-Encoding-chunk-footer.patch