diff mbox series

[wrynose] python3-urllib3: Fix CVE-2026-97689

Message ID 20261019105002.3471-1-jaipaul.cheernam@est.tech
State Under Review
Delegated to: Yoann Congal
Headers show
Series [wrynose] python3-urllib3: Fix CVE-2026-97689 | expand

Commit Message

Jaipaul Cheernam Oct. 19, 2026, 10:50 a.m. UTC
Backport the upstream fix [1] as mentioned in [2]

[1] https://github.com/urllib3/urllib3/commit/cd770b059b543be29298ea5c52afb0b1b090f5ed
[2] https://nvd.nist.gov/vuln/detail/cve-2026-97689

Testing:
  - test_response.py::test_chunk_size_line_too_long - passed
  - test_response.py::test_chunk_trailer_line_length (6 cases) - passed
  - test_socketlevel.py::test_chunked_size_line_too_long_does_not_buffer - passed
  - Full test_response.py: 161 passed, 45 skipped (no regressions)

Signed-off-by: Jaipaul Cheernam <jaipaul.cheernam@est.tech>
---
 .../python3-urllib3/CVE-2026-97689.patch      | 251 ++++++++++++++++++
 .../python/python3-urllib3_2.6.3.bb           |   6 +-
 2 files changed, 254 insertions(+), 3 deletions(-)
 create mode 100644 meta/recipes-devtools/python/python3-urllib3/CVE-2026-97689.patch
diff mbox series

Patch

diff --git a/meta/recipes-devtools/python/python3-urllib3/CVE-2026-97689.patch b/meta/recipes-devtools/python/python3-urllib3/CVE-2026-97689.patch
new file mode 100644
index 0000000000..6e5e870c36
--- /dev/null
+++ b/meta/recipes-devtools/python/python3-urllib3/CVE-2026-97689.patch
@@ -0,0 +1,251 @@ 
+From e62cca8f9e4211fa005b238237509a1c669550ba Mon Sep 17 00:00:00 2001
+From: Quentin Pradet <quentin.pradet@gmail.com>
+Date: Tue, 15 Sep 2026 22:33:41 +0400
+Subject: [PATCH] Merge commit from fork
+
+* Bound chunk-size line reads to prevent memory exhaustion DoS
+
+The streaming chunked path (HTTPResponse.stream() / read_chunked()) read
+each chunk-size line with an unbounded readline(), unlike the plain read()
+path which delegates to http.client's _MAXLINE-capped reader. A malicious
+server could advertise Transfer-Encoding: chunked and then send a very long
+run of bytes with no newline, forcing the client to buffer the entire run
+before the chunk size could be rejected.
+
+This caps the chunk-size line (and the trailing CRLF line) at _MAXLINE,
+raising ProtocolError when exceeded, matching the behaviour read() already
+had. Only the size line is bounded; chunk bodies still go through the sized
+_safe_read(), so legitimate large chunks are unaffected.
+
+Verified with a local malicious server streaming a 200 MiB unterminated
+size line: stream() previously grew RSS by ~530 MB, now stays at ~1 MB and
+fails fast, matching read(). A 5 MiB chunk body still streams correctly.
+
+* Fix a black formatting issue
+
+* Test chunked response trailer length limits
+
+* Fix number in the changelog entry
+
+---------
+
+Co-authored-by: Illia Volochii <illia.volochii@gmail.com>
+
+CVE: CVE-2026-97689
+Upstream-Status: Backport [https://github.com/urllib3/urllib3/commit/cd770b059b543be29298ea5c52afb0b1b090f5ed]
+
+Note:
+ - changeLog change is not backported
+ - Adjusted commit change as per 2.6.3 version
+
+Signed-off-by: Jaipaul Cheernam <jaipaul.cheernam@est.tech>
+---
+ src/urllib3/response.py                   | 20 +++++-
+ test/test_response.py                     | 82 ++++++++++++++++++++++-
+ test/with_dummyserver/test_socketlevel.py | 34 ++++++++++
+ 3 files changed, 132 insertions(+), 4 deletions(-)
+
+diff --git a/src/urllib3/response.py b/src/urllib3/response.py
+index ff6d1f4..58f4587 100644
+--- a/src/urllib3/response.py
++++ b/src/urllib3/response.py
+@@ -50,6 +50,13 @@ if typing.TYPE_CHECKING:
+ 
+ log = logging.getLogger(__name__)
+ 
++# Maximum length of a chunk-size line (and the trailing CRLF line) when reading a
++# chunked-transfer-encoded response. Matches ``http.client._MAXLINE`` so urllib3's
++# streaming path bounds these reads exactly like the stdlib ``.read()`` path does,
++# preventing a malicious server from forcing unbounded buffering (memory exhaustion)
++# via an unterminated chunk-size line.
++_MAX_CHUNK_LINE_LENGTH = 2**16
++
+ 
+ class ContentDecoder:
+     def decompress(self, data: bytes, max_length: int = -1) -> bytes:
+@@ -1330,7 +1337,12 @@ class HTTPResponse(BaseHTTPResponse):
+         # we'll try to read it from socket.
+         if self.chunk_left is not None:
+             return None
+-        line = self._fp.fp.readline()  # type: ignore[union-attr]
++        line = self._fp.fp.readline(_MAX_CHUNK_LINE_LENGTH + 1)  # type: ignore[union-attr]
++        if len(line) > _MAX_CHUNK_LINE_LENGTH:
++            self.close()
++            raise ProtocolError(
++                "Response chunk size line exceeded maximum allowed length"
++            ) from None
+         line = line.split(b";", 1)[0]
+         try:
+             self.chunk_left = int(line, 16)
+@@ -1438,7 +1450,11 @@ class HTTPResponse(BaseHTTPResponse):
+ 
+             # Chunk content ends with \r\n: discard it.
+             while self._fp is not None:
+-                line = self._fp.fp.readline()
++                line = self._fp.fp.readline(_MAX_CHUNK_LINE_LENGTH + 1)
++                if len(line) > _MAX_CHUNK_LINE_LENGTH:
++                    raise ProtocolError(
++                        "Response chunk trailer line exceeded maximum allowed length"
++                    )
+                 if not line:
+                     # Some sites may not end with '\r\n'.
+                     break
+diff --git a/test/test_response.py b/test/test_response.py
+index f55809f..b54a006 100644
+--- a/test/test_response.py
++++ b/test/test_response.py
+@@ -27,6 +27,7 @@ from urllib3.exceptions import (
+     SSLError,
+ )
+ from urllib3.response import (  # type: ignore[attr-defined]
++    _MAX_CHUNK_LINE_LENGTH,
+     BaseHTTPResponse,
+     BytesQueueBuffer,
+     HTTPResponse,
+@@ -1682,6 +1683,63 @@ class TestResponse:
+         assert isinstance(orig_ex, InvalidChunkLength)
+         assert orig_ex.length == fp.BAD_LENGTH_LINE.encode()
+ 
++    def test_chunk_size_line_too_long(self) -> None:
++        # A malicious server can send a chunk-size line with no newline. urllib3's
++        # streaming path must reject it with a bounded read rather than buffering
++        # the whole line (memory exhaustion). See GHSA / CVE for chunked DoS.
++        stream = [b"foooo"]
++        fp = MockChunkedEncodingLongChunkSizeLine(stream)
++        r = httplib.HTTPResponse(MockSock)  # type: ignore[arg-type]
++        r.fp = fp  # type: ignore[assignment]
++        r.chunked = True
++        r.chunk_left = None
++        resp = HTTPResponse(
++            r, preload_content=False, headers={"transfer-encoding": "chunked"}
++        )
++        with pytest.raises(ProtocolError) as ctx:
++            next(resp.read_chunked())
++
++        assert "chunk size line exceeded maximum allowed length" in str(ctx.value)
++
++    @pytest.mark.parametrize(
++        "trailer_length",
++        [
++            _MAX_CHUNK_LINE_LENGTH - 1,
++            _MAX_CHUNK_LINE_LENGTH,
++            _MAX_CHUNK_LINE_LENGTH + 1,
++        ],
++    )
++    @pytest.mark.parametrize("line_ending", [b"\r\n", b""])
++    def test_chunk_trailer_line_length(
++        self, trailer_length: int, line_ending: bytes
++    ) -> None:
++        # The line limit includes its CRLF, but not the final blank line.
++        trailer = b"X:" + b"x" * (trailer_length - 2 - len(line_ending)) + line_ending
++        fp = BytesIO(b"3\r\nfoo\r\n0\r\n" + trailer + (b"\r\n" if line_ending else b""))
++        r = httplib.HTTPResponse(MockSock, method="GET")  # type: ignore[arg-type]
++        r.fp = fp  # type: ignore[assignment]
++        resp = HTTPResponse(
++            r,
++            preload_content=False,
++            headers={"transfer-encoding": "chunked"},
++            original_response=r,
++        )
++        chunks = resp.read_chunked()
++        assert next(chunks) == b"foo"
++
++        if trailer_length > _MAX_CHUNK_LINE_LENGTH:
++            with pytest.raises(
++                ProtocolError,
++                match="Response chunk trailer line exceeded maximum allowed length",
++            ):
++                next(chunks)
++        else:
++            assert list(chunks) == []
++
++        assert fp.closed
++        assert r.isclosed()
++        assert resp.closed
++
+     def test_truncated_before_chunk(self) -> None:
+         stream = [b"foooo", b"bbbbaaaaar"]
+         fp = MockChunkedNoChunks(stream)
+@@ -1941,8 +1999,20 @@ class MockChunkedEncodingResponse:
+                 self.cur_chunk = self.cur_chunk[amt:]
+             return chunk_part
+ 
+-    def readline(self) -> bytes:
+-        return self.pop_current_chunk(till_crlf=True)
++    def readline(self, amt: int = -1) -> bytes:
++        # Emulate a real file object's readline(size): return bytes up to and
++        # including the first newline, or at most ``amt`` bytes when ``amt >= 0``,
++        # whichever comes first.
++        if len(self.cur_chunk) <= 0:
++            self.cur_chunk = self._pop_new_chunk()
++        line = self.cur_chunk
++        newline_index = line.find(b"\n")
++        if newline_index != -1:
++            line = line[: newline_index + 1]
++        if 0 <= amt < len(line):
++            line = line[:amt]
++        self.cur_chunk = self.cur_chunk[len(line) :]
++        return line
+ 
+     def read(self, amt: int = -1) -> bytes:
+         return self.pop_current_chunk(amt)
+@@ -1970,6 +2040,14 @@ class MockChunkedInvalidChunkLength(MockChunkedEncodingResponse):
+         return f"{self.BAD_LENGTH_LINE}{chunk.decode()}\r\n".encode()
+ 
+ 
++class MockChunkedEncodingLongChunkSizeLine(MockChunkedEncodingResponse):
++    def _encode_chunk(self, chunk: bytes) -> bytes:
++        # A chunk-size line far longer than _MAX_CHUNK_LINE_LENGTH with no
++        # newline, simulating a malicious server that never terminates the
++        # size line (memory-exhaustion attack).
++        return b"f" * (2**16 + 1024)
++
++
+ class MockChunkedEncodingWithoutCRLFOnEnd(MockChunkedEncodingResponse):
+     def _encode_chunk(self, chunk: bytes) -> bytes:
+         return "{:X}\r\n{}{}".format(
+diff --git a/test/with_dummyserver/test_socketlevel.py b/test/with_dummyserver/test_socketlevel.py
+index 016484d..d603b8a 100644
+--- a/test/with_dummyserver/test_socketlevel.py
++++ b/test/with_dummyserver/test_socketlevel.py
+@@ -2289,6 +2289,40 @@ class TestStream(SocketDummyServerTestCase):
+ 
+             done_event.set()
+ 
++    def test_chunked_size_line_too_long_does_not_buffer(self) -> None:
++        # A malicious server can advertise chunked encoding and then send an
++        # unterminated chunk-size line (no newline). The streaming path must
++        # reject it with a bounded read instead of buffering the whole run,
++        # which would exhaust memory.
++        done_event = Event()
++        # Well above the 65536-byte cap, but small enough for a fast test.
++        run_length = 512 * 1024
++
++        def socket_handler(listener: socket.socket) -> None:
++            sock = listener.accept()[0]
++
++            buf = b""
++            while not buf.endswith(b"\r\n\r\n"):
++                buf += sock.recv(65536)
++
++            sock.sendall(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
++            # A chunk-size line that never terminates with a newline.
++            with contextlib.suppress(OSError):
++                sock.sendall(b"f" * run_length)
++
++            done_event.wait(5)
++            sock.close()
++
++        self._start_server(socket_handler)
++
++        with HTTPConnectionPool(self.host, self.port, retries=False) as pool:
++            r = pool.request("GET", "/", timeout=LONG_TIMEOUT, preload_content=False)
++            with pytest.raises(ProtocolError) as ctx:
++                next(r.stream(65536))
++            assert "chunk size line exceeded maximum allowed length" in str(ctx.value)
++
++            done_event.set()
++
+     def test_large_compressed_stream(self) -> None:
+         done_event = Event()
+         expected_total_length = 296085
diff --git a/meta/recipes-devtools/python/python3-urllib3_2.6.3.bb b/meta/recipes-devtools/python/python3-urllib3_2.6.3.bb
index 5976c5c3b4..537a68cdab 100644
--- a/meta/recipes-devtools/python/python3-urllib3_2.6.3.bb
+++ b/meta/recipes-devtools/python/python3-urllib3_2.6.3.bb
@@ -3,9 +3,9 @@  HOMEPAGE = "https://github.com/urllib3/urllib3"
 LICENSE = "MIT"
 LIC_FILES_CHKSUM = "file://LICENSE.txt;md5=52d273a3054ced561275d4d15260ecda"
 
-SRC_URI += " \
-    file://CVE-2026-44431.patch \
-"
+SRC_URI += "file://CVE-2026-44431.patch \
+            file://CVE-2026-97689.patch \
+           "
 SRC_URI[sha256sum] = "1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"
 
 inherit pypi python_hatchling