new file mode 100644
@@ -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
@@ -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
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