Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-urllib3_1 for openSUSE:Factory checked in at 2026-10-13 17:43:38 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-urllib3_1 (Old) and /work/SRC/openSUSE:Factory/.python-urllib3_1.new.3698736 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-urllib3_1" Tue Oct 13 17:43:38 2026 rev:16 rq:1384605 version:1.26.20 Changes: -------- --- /work/SRC/openSUSE:Factory/python-urllib3_1/python-urllib3_1.changes 2026-06-16 18:29:21.825430968 +0200 +++ /work/SRC/openSUSE:Factory/.python-urllib3_1.new.3698736/python-urllib3_1.changes 2026-10-13 17:44:20.827805365 +0200 @@ -1,0 +2,36 @@ +Tue Oct 13 12:31:43 UTC 2026 - Daniel Garcia <[email protected]> + +- Fix backported patch CVE-2026-97687.2.patch, bsc#1283908 + +------------------------------------------------------------------- +Thu Oct 8 10:22:20 UTC 2026 - Daniel Garcia <[email protected]> + +- Support Python >= 3.14.8 + Add upstream patches: + * avoid-flushing-swallowed-gzip-trailing-garbage.patch, gh#urllib3/urllib3#5271 + * pass-server_hostname-in-test_start_closed_socket.patch, gh#urllib3/urllib3#5289 + +------------------------------------------------------------------- +Wed Oct 7 09:08:31 UTC 2026 - Daniel Garcia <[email protected]> + +- CVE-2026-97689: denial of service due to unbounded memory allocation + in chunk parser (bsc#1283910) + Add patch CVE-2026-97689.patch + +------------------------------------------------------------------- +Wed Oct 7 09:02:24 UTC 2026 - Daniel Garcia <[email protected]> + +- CVE-2026-97688: denial of service due to infinite loop during + chunked Deflate decoding (bsc#1283909) + Add patch CVE-2026-97688.patch + +------------------------------------------------------------------- +Tue Oct 6 09:55:35 UTC 2026 - Daniel Garcia <[email protected]> + +- CVE-2026-97687: traffic interception due to possible HTTPS proxy TLS + configuration override (bsc#1283908) + - Add upstream patches: + * CVE-2026-97687.1.patch, gh#urllib3/urllib3@b6447295fff7 + * CVE-2026-97687.2.patch, gh#urllib3/urllib3@07408cec79d1 + +------------------------------------------------------------------- New: ---- CVE-2026-97687.1.patch CVE-2026-97687.2.patch CVE-2026-97688.patch CVE-2026-97689.patch avoid-flushing-swallowed-gzip-trailing-garbage.patch pass-server_hostname-in-test_start_closed_socket.patch ----------(New B)---------- New: - Add upstream patches: * CVE-2026-97687.1.patch, gh#urllib3/urllib3@b6447295fff7 * CVE-2026-97687.2.patch, gh#urllib3/urllib3@07408cec79d1 New: - Fix backported patch CVE-2026-97687.2.patch, bsc#1283908 New: chunked Deflate decoding (bsc#1283909) Add patch CVE-2026-97688.patch New: in chunk parser (bsc#1283910) Add patch CVE-2026-97689.patch New: Add upstream patches: * avoid-flushing-swallowed-gzip-trailing-garbage.patch, gh#urllib3/urllib3#5271 * pass-server_hostname-in-test_start_closed_socket.patch, gh#urllib3/urllib3#5289 New: * avoid-flushing-swallowed-gzip-trailing-garbage.patch, gh#urllib3/urllib3#5271 * pass-server_hostname-in-test_start_closed_socket.patch, gh#urllib3/urllib3#5289 ----------(New E)---------- ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-urllib3_1.spec ++++++ --- /var/tmp/diff_new_pack.S1IFwK/_old 2026-10-13 17:44:22.142860478 +0200 +++ /var/tmp/diff_new_pack.S1IFwK/_new 2026-10-13 17:44:22.144860562 +0200 @@ -48,6 +48,18 @@ Patch5: CVE-2026-21441.patch # PATCH-FIX-UPSTREAM CVE-2026-44431.patch bsc#1265267 Patch6: CVE-2026-44431.patch +# PATCH-FIX-UPSTREAM CVE-2026-97687.1.patch bsc#1283908 gh#urllib3/urllib3@b6447295fff7 +Patch7: CVE-2026-97687.1.patch +# PATCH-FIX-UPSTREAM CVE-2026-97687.2.patch bsc#1283908 gh#urllib3/urllib3@07408cec79d1 +Patch8: CVE-2026-97687.2.patch +# PATCH-FIX-UPSTREAM CVE-2026-97688.patch bsc#1283909 gh#urllib3/urllib3@ea2ad7b21a80 +Patch9: CVE-2026-97688.patch +# PATCH-FIX-UPSTREAM CVE-2026-97689.patch bsc#1283910 gh#urllib3/urllib3@cd770b059b54 +Patch10: CVE-2026-97689.patch +# PATCH-FIX-UPSTREAM avoid-flushing-swallowed-gzip-trailing-garbage.patch gh#urllib3/urllib3#5271 +Patch11: avoid-flushing-swallowed-gzip-trailing-garbage.patch +# PATCH-FIX-UPSTREAM pass-server_hostname-in-test_start_closed_socket.patch gh#urllib3/urllib3#5289 +Patch12: pass-server_hostname-in-test_start_closed_socket.patch BuildRequires: %{python_module base >= 3.7} BuildRequires: %{python_module pip} BuildRequires: %{python_module setuptools} ++++++ CVE-2026-97687.1.patch ++++++ Index: urllib3-1.26.20/changelog/2577.bugfix.rst =================================================================== --- /dev/null +++ urllib3-1.26.20/changelog/2577.bugfix.rst @@ -0,0 +1,2 @@ +Fixed usage of ``proxy_ssl_context`` with ``ProxyManager`` when ``use_forwarding_for_https=True``. +Passing ``ssl_context`` instead of ``proxy_ssl_context`` for HTTPS proxies in this configuration now emits a ``FutureWarning`` and will raise an error in v3.0. Index: urllib3-1.26.20/src/urllib3/connection.py =================================================================== --- urllib3-1.26.20.orig/src/urllib3/connection.py +++ urllib3-1.26.20/src/urllib3/connection.py @@ -158,6 +158,13 @@ class HTTPConnection(_HTTPConnection, ob """ self._dns_host = value + @property + def proxy_is_forwarding(self): + """ + Return True if a forwarding proxy is configured, else return False + """ + return bool(self.proxy) and self._tunnel_host is None + def _new_conn(self): """Establish a socket connection and set nodelay settings on it. @@ -392,17 +399,31 @@ class HTTPSConnection(HTTPConnection): SystemTimeWarning, ) + # Forwarding proxies should use proxy SSL context for + # wrapping since that's the connection being established, + # whereas tunneling proxies should use the connection's SSL + # context. + # However, for backwards compatibility reasons, if the proxy + # is forwarding but no proxy SSL context is provided, we + # fall back to using the connection's SSL context until + # urllib3 v3.0. Appropriate warning is emitted in + # ``ProxyManager.__init__``. + if self.proxy_is_forwarding and self.proxy_config is not None: + ssl_context = self.proxy_config.ssl_context + else: + ssl_context = self.ssl_context + # Wrap socket using verification with the root certs in # trusted_root_certs default_ssl_context = False - if self.ssl_context is None: + if ssl_context is None: default_ssl_context = True - self.ssl_context = create_urllib3_context( + ssl_context = create_urllib3_context( ssl_version=resolve_ssl_version(self.ssl_version), cert_reqs=resolve_cert_reqs(self.cert_reqs), ) - context = self.ssl_context + context = ssl_context context.verify_mode = resolve_cert_reqs(self.cert_reqs) # Try to load OS default certs if none are given. Index: urllib3-1.26.20/src/urllib3/poolmanager.py =================================================================== --- urllib3-1.26.20.orig/src/urllib3/poolmanager.py +++ urllib3-1.26.20/src/urllib3/poolmanager.py @@ -1,5 +1,7 @@ from __future__ import absolute_import +import warnings + import collections import functools import logging @@ -499,6 +501,21 @@ class ProxyManager(PoolManager): if proxy.scheme not in ("http", "https"): raise ProxySchemeUnknown(proxy.scheme) + if ( + use_forwarding_for_https + and proxy.scheme == "https" + and connection_pool_kw.get("ssl_context") is not None + ): + warnings.warn( + "Passing ssl_context when use_forwarding_for_https=True is deprecated " + "and will raise an error in urllib3 v3.0. " + "Use proxy_ssl_context to configure the TLS connection to the proxy.", + FutureWarning, + stacklevel=2, + ) + if proxy_ssl_context is None: + proxy_ssl_context = connection_pool_kw.get("ssl_context") + if not proxy.port: port = port_by_scheme.get(proxy.scheme, 80) proxy = proxy._replace(port=port) Index: urllib3-1.26.20/test/with_dummyserver/test_proxy_poolmanager.py =================================================================== --- urllib3-1.26.20.orig/test/with_dummyserver/test_proxy_poolmanager.py +++ urllib3-1.26.20/test/with_dummyserver/test_proxy_poolmanager.py @@ -1,3 +1,5 @@ +import typing +import contextlib import json import os.path import shutil @@ -23,6 +25,7 @@ from dummyserver.testcase import HTTPDum from urllib3._collections import HTTPHeaderDict from urllib3.util.retry import RequestHistory from urllib3.connectionpool import VerifiedHTTPSConnection, connection_from_url +from urllib3.connection import HTTPSConnection from urllib3.exceptions import ( ConnectTimeoutError, InsecureRequestWarning, @@ -149,6 +152,41 @@ class TestHTTPProxyManager(HTTPDummyProx r = https.request("GET", "%s/" % self.https_url) assert r.status == 200 + @requires_network + @pytest.mark.parametrize( + "proxy_ssl_context_kw", + [ + ("ssl_context",), + ("proxy_ssl_context",), + ("ssl_context", "proxy_ssl_context"), + ], + ) + def test_https_proxy_forwarding_for_https_with_custom_context(self, proxy_ssl_context_kw): + """ + Test that an HTTP request succeeds when using a forwarding HTTPS + proxy with an SSL context provided via either + ``proxy_ssl_context``, ``ssl_context`` (fallback), or both. + """ + + proxy_ctx = ssl.create_default_context() + proxy_ctx.load_verify_locations(DEFAULT_CA) + + warning_checker: contextlib.AbstractContextManager[typing.Any] + if "ssl_context" in proxy_ssl_context_kw: + warning_checker = pytest.warns(Warning, match="ssl_context") + else: + warning_checker = contextlib.nullcontext() + with warning_checker: + proxy = proxy_from_url( + self.https_proxy_url, + **{kw: proxy_ctx for kw in proxy_ssl_context_kw}, + use_forwarding_for_https=True, + ) + + with proxy: + resp = proxy.request("GET", self.https_url) + assert resp.status == 200 + def test_nagle_proxy(self): """Test that proxy connections do not have TCP_NODELAY turned on""" with ProxyManager(self.proxy_url) as http: @@ -701,15 +739,8 @@ class TestHTTPProxyManager(HTTPDummyProx assert type(e.value.reason) == SSLError @requires_network - @pytest.mark.parametrize( - ["proxy_scheme", "use_forwarding_for_https"], - [ - ("http", False), - ("https", False), - ("https", True), - ], - ) - def test_proxy_https_target_tls_error(self, proxy_scheme, use_forwarding_for_https): + @pytest.mark.parametrize("proxy_scheme", ["http", "https"]) + def test_proxy_https_target_tls_error(self, proxy_scheme): _should_skip_https_in_https(proxy_scheme, "https") proxy_url = self.https_proxy_url if proxy_scheme == "https" else self.proxy_url @@ -721,12 +752,102 @@ class TestHTTPProxyManager(HTTPDummyProx proxy_url, proxy_ssl_context=proxy_ctx, ssl_context=ctx, - use_forwarding_for_https=use_forwarding_for_https, ) as proxy: with pytest.raises(MaxRetryError) as e: proxy.request("GET", self.https_url) assert type(e.value.reason) == SSLError + @requires_network + def test_forwarding_proxy_tls_error_with_fallback(self) -> None: + """ + Test that when only ``ssl_context`` is passed to a forwarding + HTTPS proxy, a ``FutureWarning`` is emitted and if the TLS + handshake with the proxy fails using that context, the error is + a ``ProxyError`` wrapping the original TLS error. + """ + ctx = ssl.create_default_context() + + with pytest.warns(Warning, match="ssl_context"): + proxy = proxy_from_url( + self.https_proxy_url, + ssl_context=ctx, + use_forwarding_for_https=True, + ) + + with proxy: + with pytest.raises(MaxRetryError) as e: + proxy.request("GET", self.https_url) + assert isinstance(e.value.reason, SSLError) + + @requires_network + def test_forwarding_proxy_ssl_context_precedence(self) -> None: + """ + Test that when both ``ssl_context`` and ``proxy_ssl_context`` + are passed to a forwarding HTTPS proxy, ``proxy_ssl_context`` is + used for the proxy connection and ``ssl_context`` is ignored + (with a warning). + """ + ssl_context = ssl.create_default_context(cafile=DEFAULT_CA) + proxy_ssl_context = ssl.create_default_context(cafile=DEFAULT_CA) + + with pytest.warns(Warning, match="ssl_context"): + proxy = proxy_from_url( + self.https_proxy_url, + proxy_ssl_context=proxy_ssl_context, + ssl_context=ssl_context, + use_forwarding_for_https=True, + ) + + with proxy: + pool = proxy.connection_from_url(self.https_url) + with contextlib.closing(pool._new_conn()) as conn: + conn = typing.cast(HTTPSConnection, conn) + conn.connect() + assert isinstance(conn.sock, ssl.SSLSocket) + assert conn.sock.context is proxy_ssl_context + + @requires_network + def test_forwarding_proxy_ssl_context_fallback(self) -> None: + """ + Test that when only ``ssl_context`` is passed to a forwarding + HTTPS proxy, a ``FutureWarning`` is emitted and ``ssl_context`` + is used as the proxy TLS context (fallback). + """ + ssl_context = ssl.create_default_context(cafile=DEFAULT_CA) + + with pytest.warns(Warning, match="ssl_context"): + proxy = proxy_from_url( + self.https_proxy_url, + ssl_context=ssl_context, + use_forwarding_for_https=True, + ) + + with proxy: + pool = proxy.connection_from_url(self.https_url) + with contextlib.closing(pool._new_conn()) as conn: + conn = typing.cast(HTTPSConnection, conn) + conn.connect() + assert isinstance(conn.sock, ssl.SSLSocket) + assert conn.sock.context is ssl_context + + @requires_network + def test_forwarding_non_https_proxy_ssl_context_fallback(self) -> None: + """ + Test that when only ``ssl_context`` is passed to a forwarding + non-HTTPS proxy, no warning or error is emitted. + """ + ssl_context = ssl.create_default_context(cafile=DEFAULT_CA) + # No warning/error should be emitted even when ``ssl_context`` + # is provided to a non-HTTPS proxy for some reason. + proxy = proxy_from_url( + self.proxy_url, + ssl_context=ssl_context, + use_forwarding_for_https=True, + ) + with proxy: + resp = proxy.request("GET", self.https_url) + assert resp.status == 200 + def test_scheme_host_case_insensitive(self): """Assert that upper-case schemes and hosts are normalized.""" with proxy_from_url(self.proxy_url.upper(), ca_certs=DEFAULT_CA) as http: ++++++ CVE-2026-97687.2.patch ++++++ Index: urllib3-1.26.20/changelog/5093.bugfix.rst =================================================================== --- /dev/null +++ urllib3-1.26.20/changelog/5093.bugfix.rst @@ -0,0 +1,7 @@ +Fixed ``HTTPSConnection.connect()`` overriding +``ProxyConfig.ssl_context``'s certificate policy and proxy identity checks with +the target connection's TLS settings when forwarding through an HTTPS proxy. + +``HTTPSConnection`` no longer applies target SNI, assertions, or client +credentials to forwarding proxy handshakes and continues to use its +``ssl_context`` as a fallback when an HTTPS proxy forwards an HTTP target. Index: urllib3-1.26.20/src/urllib3/connection.py =================================================================== --- urllib3-1.26.20.orig/src/urllib3/connection.py +++ urllib3-1.26.20/src/urllib3/connection.py @@ -408,8 +408,11 @@ class HTTPSConnection(HTTPConnection): # fall back to using the connection's SSL context until # urllib3 v3.0. Appropriate warning is emitted in # ``ProxyManager.__init__``. + cert_reqs = self.cert_reqs if self.proxy_is_forwarding and self.proxy_config is not None: ssl_context = self.proxy_config.ssl_context + if ssl_context is not None: + cert_reqs = ssl_context.verify_mode else: ssl_context = self.ssl_context @@ -424,7 +427,7 @@ class HTTPSConnection(HTTPConnection): ) context = ssl_context - context.verify_mode = resolve_cert_reqs(self.cert_reqs) + context.verify_mode = resolve_cert_reqs(cert_reqs) # Try to load OS default certs if none are given. # Works well on Windows (requires Python3.4+) @@ -437,18 +440,27 @@ class HTTPSConnection(HTTPConnection): ): context.load_default_certs() - self.sock = ssl_wrap_socket( - sock=conn, - keyfile=self.key_file, - certfile=self.cert_file, - key_password=self.key_password, - ca_certs=self.ca_certs, - ca_cert_dir=self.ca_cert_dir, - ca_cert_data=self.ca_cert_data, - server_hostname=server_hostname, - ssl_context=context, - tls_in_tls=tls_in_tls, - ) + if self.proxy_is_forwarding and self.proxy_config is not None: + self.sock = self._connect_tls_proxy(self.host, self.sock) + self.is_verified = self.proxy_is_verified is True + else: + self.sock = ssl_wrap_socket( + sock=conn, + keyfile=self.key_file, + certfile=self.cert_file, + key_password=self.key_password, + ca_certs=self.ca_certs, + ca_cert_dir=self.ca_cert_dir, + ca_cert_data=self.ca_cert_data, + server_hostname=server_hostname, + ssl_context=context, + tls_in_tls=tls_in_tls, + ) + + self.is_verified = ( + context.verify_mode == ssl.CERT_REQUIRED + or self.assert_fingerprint is not None + ) # If we're using all defaults and the connection # is TLSv1 or TLSv1.1 we throw a DeprecationWarning @@ -492,17 +504,28 @@ class HTTPSConnection(HTTPConnection): ) _match_hostname(cert, self.assert_hostname or server_hostname) - self.is_verified = ( - context.verify_mode == ssl.CERT_REQUIRED - or self.assert_fingerprint is not None - ) - def _connect_tls_proxy(self, hostname, conn): """ Establish a TLS connection to the proxy using the provided SSL context. """ - proxy_config = self.proxy_config - ssl_context = proxy_config.ssl_context + proxy_ssl_context = self.proxy_config.ssl_context + if proxy_ssl_context is not None: + # Prefer the proxy's cert policy for the proxy connection + ssl_context = proxy_ssl_context + cert_reqs = proxy_ssl_context.verify_mode + ca_certs = None + ca_cert_dir = None + ca_cert_data = None + ssl_version = None + else: + # Otherwise we inherit the pool's cert policies + ssl_context = self.ssl_context if self.proxy_is_forwarding else None + cert_reqs = self.cert_reqs + ca_certs = self.ca_certs + ca_cert_dir = self.ca_cert_dir + ca_cert_data = self.ca_cert_data + ssl_version = self.ssl_version + if ssl_context: # If the user provided a proxy context, we assume CA and client # certificates have already been set @@ -513,20 +536,20 @@ class HTTPSConnection(HTTPConnection): ) ssl_context = create_proxy_ssl_context( - self.ssl_version, - self.cert_reqs, - self.ca_certs, - self.ca_cert_dir, - self.ca_cert_data, + ssl_version, + cert_reqs, + ca_certs, + ca_cert_dir, + ca_cert_data, ) # If no cert was provided, use only the default options for server # certificate validation socket = ssl_wrap_socket( sock=conn, - ca_certs=self.ca_certs, - ca_cert_dir=self.ca_cert_dir, - ca_cert_data=self.ca_cert_data, + ca_certs=ca_certs, + ca_cert_dir=ca_cert_dir, + ca_cert_data=ca_cert_data, server_hostname=hostname, ssl_context=ssl_context, ) Index: urllib3-1.26.20/src/urllib3/poolmanager.py =================================================================== --- urllib3-1.26.20.orig/src/urllib3/poolmanager.py +++ urllib3-1.26.20/src/urllib3/poolmanager.py @@ -501,6 +501,9 @@ class ProxyManager(PoolManager): if proxy.scheme not in ("http", "https"): raise ProxySchemeUnknown(proxy.scheme) + # Keep the deprecated ssl_context fallback on the manager for + # compatibility, while passing only explicit proxy policy to connections. + self.proxy_ssl_context = proxy_ssl_context if ( use_forwarding_for_https and proxy.scheme == "https" @@ -513,8 +516,8 @@ class ProxyManager(PoolManager): FutureWarning, stacklevel=2, ) - if proxy_ssl_context is None: - proxy_ssl_context = connection_pool_kw.get("ssl_context") + if self.proxy_ssl_context is None: + self.proxy_ssl_context = connection_pool_kw.get("ssl_context") if not proxy.port: port = port_by_scheme.get(proxy.scheme, 80) @@ -522,7 +525,6 @@ class ProxyManager(PoolManager): self.proxy = proxy self.proxy_headers = proxy_headers or {} - self.proxy_ssl_context = proxy_ssl_context self.proxy_config = ProxyConfig(proxy_ssl_context, use_forwarding_for_https) connection_pool_kw["_proxy"] = self.proxy Index: urllib3-1.26.20/test/with_dummyserver/test_proxy_poolmanager.py =================================================================== --- urllib3-1.26.20.orig/test/with_dummyserver/test_proxy_poolmanager.py +++ urllib3-1.26.20/test/with_dummyserver/test_proxy_poolmanager.py @@ -117,6 +117,18 @@ class TestHTTPProxyManager(HTTPDummyProx assert "is not supported in Python 2" in str(excinfo.value) @withPyOpenSSL + def test_https_proxy_with_proxy_ssl_context_pyopenssl(self) -> None: + proxy_ssl_context = create_urllib3_context() + proxy_ssl_context.load_verify_locations(DEFAULT_CA) + + with proxy_from_url( + self.https_proxy_url, + proxy_ssl_context=proxy_ssl_context, + ) as https: + response = https.request("GET", f"{self.http_url}/") + assert response.status == 200 + + @withPyOpenSSL @onlyPy3 def test_https_proxy_pyopenssl_not_supported(self): with proxy_from_url(self.https_proxy_url, ca_certs=DEFAULT_CA) as https: @@ -831,6 +843,22 @@ class TestHTTPProxyManager(HTTPDummyProx assert conn.sock.context is ssl_context @requires_network + def test_https_proxy_to_http_target_ssl_context_fallback(self) -> None: + ssl_context = create_urllib3_context() + ssl_context.load_verify_locations(DEFAULT_CA) + + with proxy_from_url( + self.https_proxy_url, + ssl_context=ssl_context, + ) as proxy: + pool = proxy.connection_from_url(self.http_url) + with contextlib.closing(pool._new_conn()) as conn: + conn = typing.cast(HTTPSConnection, conn) + conn.connect() + assert isinstance(conn.sock, ssl.SSLSocket) + assert conn.sock.context is ssl_context + + @requires_network def test_forwarding_non_https_proxy_ssl_context_fallback(self) -> None: """ Test that when only ``ssl_context`` is passed to a forwarding @@ -878,6 +906,12 @@ class TestHTTPProxyManager(HTTPDummyProx with pytest.raises(ProxySchemeUnknown, match=error_msg): proxy_from_url(url) + def test_is_verified_https_proxy_to_http_target_without_proxy_config(self) -> None: + with proxy_from_url(self.https_proxy_url, ca_certs=DEFAULT_CA) as https: + https.connection_pool_kw["_proxy_config"] = None + r = https.request("GET", f"{self.http_url}/") + assert r.status == 200 + @pytest.mark.skipif(not HAS_IPV6, reason="Only runs on IPv6 systems") class TestIPv6HTTPProxyManager(IPv6HTTPDummyProxyTestCase): ++++++ CVE-2026-97688.patch ++++++ Index: urllib3-1.26.20/changelog/GHSA-gh4c-6fx4-qh6g.bugfix.rst =================================================================== --- /dev/null +++ urllib3-1.26.20/changelog/GHSA-gh4c-6fx4-qh6g.bugfix.rst @@ -0,0 +1 @@ +Fixed an infinite loop in ``HTTPResponse.stream()`` and ``HTTPResponse.read_chunked()`` when decoding chunked responses with ``Content-Encoding: deflate`` and trailing data after the compressed stream. Index: urllib3-1.26.20/src/urllib3/response.py =================================================================== --- urllib3-1.26.20.orig/src/urllib3/response.py +++ urllib3-1.26.20/src/urllib3/response.py @@ -53,6 +53,10 @@ class DeflateDecoder(object): def decompress(self, data, max_length = -1): data = self._unfed_data + data self._unfed_data = b"" + # Data received after EOF cannot produce more output from this + # Deflate stream. + if self._obj.eof: + return b"" if not data and not self._obj.unconsumed_tail: return data original_max_length = max_length @@ -95,7 +99,9 @@ class DeflateDecoder(object): @property def has_unconsumed_tail(self): return bool(self._unfed_data) or ( - bool(self._obj.unconsumed_tail) and not self._first_try + bool(self._obj.unconsumed_tail) + and not self._first_try + and not self._obj.eof ) Index: urllib3-1.26.20/test/test_response.py =================================================================== --- urllib3-1.26.20.orig/test/test_response.py +++ urllib3-1.26.20/test/test_response.py @@ -8,7 +8,7 @@ import ssl import zlib from base64 import b64decode from io import BufferedReader, BytesIO, TextIOWrapper -from test import onlyBrotlipy +from test import LONG_TIMEOUT, onlyBrotlipy try: import unittest.mock as mock @@ -1104,6 +1104,38 @@ class TestResponse(object): resp = HTTPResponse(fp, retries=retry) assert resp.geturl() == "https://www.example.com" + @pytest.mark.timeout(LONG_TIMEOUT) + @pytest.mark.parametrize( + "data", + [d[1] for d in _test_compressor_params], + ids=[d[0] for d in _test_compressor_params], + ) + def test_read_chunked_with_trailing_data_does_not_hang(self, request, data): + if data is None: + pytest.skip(f"Proper {request.node.callspec.id} decoder is not available") + # The decoded body must fill multiple bounded reads so EOF is reached + # while trailing data remains in the decoder's input buffer. + original_data = b"A" * 100 + content_encoding, compress_func = data + compressed_data = compress_func(original_data) + b"tail" + httplib_r = httplib.HTTPResponse(MockSock) # type: ignore[arg-type] + httplib_r.fp = MockChunkedEncodingResponse([compressed_data]) # type: ignore[assignment] + r = HTTPResponse( + httplib_r, + preload_content=False, + headers={ + "transfer-encoding": "chunked", + "content-encoding": content_encoding, + }, + ) + + stream = r.stream(len(original_data) // 2, decode_content=True) + if content_encoding in ("br", "zstd"): + with pytest.raises(DecodeError): + list(stream) + else: + assert b"".join(stream) == original_data + @pytest.mark.parametrize( ["payload", "expected_stream"], [ ++++++ CVE-2026-97689.patch ++++++ Index: urllib3-1.26.20/changelog/GHSA-vxq7-64xx-v4gw.bugfix.rst =================================================================== --- /dev/null +++ urllib3-1.26.20/changelog/GHSA-vxq7-64xx-v4gw.bugfix.rst @@ -0,0 +1 @@ +Fixed a denial-of-service issue where a malicious server could cause unbounded memory usage in the streaming chunked-transfer-encoding read path (``HTTPResponse.stream()`` / ``HTTPResponse.read_chunked()``) by sending an unterminated chunk-size line. The chunk-size line and trailing CRLF are now read with a bounded length matching :data:`http.client._MAXLINE`, raising :class:`~urllib3.exceptions.ProtocolError` if exceeded, consistent with the non-streaming ``HTTPResponse.read()`` path. Index: urllib3-1.26.20/src/urllib3/response.py =================================================================== --- urllib3-1.26.20.orig/src/urllib3/response.py +++ urllib3-1.26.20/src/urllib3/response.py @@ -39,6 +39,13 @@ from .util.response import is_fp_closed, 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 DeflateDecoder(object): def __init__(self): @@ -1018,7 +1025,12 @@ class HTTPResponse(io.IOBase): # we'll try to read it from socket. if self.chunk_left is not None: return - line = self._fp.fp.readline() + 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" + ) line = line.split(b";", 1)[0] try: self.chunk_left = int(line, 16) @@ -1111,7 +1123,11 @@ class HTTPResponse(io.IOBase): # Chunk content ends with \r\n: discard it. while True: - 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 Index: urllib3-1.26.20/test/test_response.py =================================================================== --- urllib3-1.26.20.orig/test/test_response.py +++ urllib3-1.26.20/test/test_response.py @@ -30,6 +30,7 @@ from urllib3.exceptions import ( ) from urllib3.packages.six.moves import http_client as httplib from urllib3.response import HTTPResponse, BytesQueueBuffer, brotli +from urllib3.response import _MAX_CHUNK_LINE_LENGTH from urllib3.util.response import is_fp_closed from urllib3.util.retry import RequestHistory, Retry @@ -1019,6 +1020,63 @@ class TestResponse(object): assert isinstance(orig_ex, InvalidChunkLength) assert orig_ex.length == six.b(fp.BAD_LENGTH_LINE) + 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_chunked_response_without_crlf_on_end(self): stream = [b"foo", b"bar", b"baz"] fp = MockChunkedEncodingWithoutCRLFOnEnd(stream) @@ -1261,8 +1319,20 @@ class MockChunkedEncodingResponse(object self.cur_chunk = self.cur_chunk[amt:] return chunk_part - def readline(self): - return self.pop_current_chunk(till_crlf=True) + def readline(self, amt=-1): + # 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=-1): return self.pop_current_chunk(amt) @@ -1287,6 +1357,14 @@ class MockChunkedInvalidChunkLength(Mock return "%s%s\r\n" % (self.BAD_LENGTH_LINE, chunk.decode()) +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): return "%X\r\n%s%s" % ( Index: urllib3-1.26.20/test/with_dummyserver/test_socketlevel.py =================================================================== --- urllib3-1.26.20.orig/test/with_dummyserver/test_socketlevel.py +++ urllib3-1.26.20/test/with_dummyserver/test_socketlevel.py @@ -33,6 +33,7 @@ except ImportError: pass +import contextlib import os import os.path import platform @@ -1872,6 +1873,40 @@ class TestStream(SocketDummyServerTestCa 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() + class TestBadContentLength(SocketDummyServerTestCase): def test_enforce_content_length_get(self): ++++++ avoid-flushing-swallowed-gzip-trailing-garbage.patch ++++++ Index: urllib3-1.26.20/changelog/5268.bugfix.rst =================================================================== --- /dev/null +++ urllib3-1.26.20/changelog/5268.bugfix.rst @@ -0,0 +1,2 @@ +Prevented a suppressed :class:`GzipDecoder` decompression error caused by invalid +trailing data from resurfacing during ``flush()`` on newer Python versions. Index: urllib3-1.26.20/src/urllib3/response.py =================================================================== --- urllib3-1.26.20.orig/src/urllib3/response.py +++ urllib3-1.26.20/src/urllib3/response.py @@ -183,6 +183,11 @@ class GzipDecoder(object): def has_unconsumed_tail(self): return bool(self._unconsumed_tail) + def flush(self) -> bytes: + if self._state == GzipDecoderState.SWALLOW_DATA: + return b"" + return self._obj.flush() + if brotli is not None: Index: urllib3-1.26.20/test/test_response.py =================================================================== --- urllib3-1.26.20.orig/test/test_response.py +++ urllib3-1.26.20/test/test_response.py @@ -31,6 +31,7 @@ from urllib3.exceptions import ( from urllib3.packages.six.moves import http_client as httplib from urllib3.response import HTTPResponse, BytesQueueBuffer, brotli from urllib3.response import _MAX_CHUNK_LINE_LENGTH +from urllib3.response import GzipDecoder from urllib3.util.response import is_fp_closed from urllib3.util.retry import RequestHistory, Retry @@ -244,6 +245,16 @@ class TestResponse(object): assert ret == b"foofoofoo" + def test_decode_gzip_swallow_garbage_does_not_flush(self) -> None: + decoder = GzipDecoder() + data = gzip.compress(b"foo") + b"garbage" + + assert decoder.decompress(data) == b"foo" + decoder._obj = mock.Mock(flush=mock.Mock(side_effect=zlib.error)) + + assert decoder.flush() == b"" + decoder._obj.flush.assert_not_called() + def test_chunked_decoding_gzip_swallow_garbage(self): compress = zlib.compressobj(6, zlib.DEFLATED, 16 + zlib.MAX_WBITS) data = compress.compress(b"foo") ++++++ pass-server_hostname-in-test_start_closed_socket.patch ++++++ Index: urllib3-1.26.20/changelog/5289.misc.rst =================================================================== --- /dev/null +++ urllib3-1.26.20/changelog/5289.misc.rst @@ -0,0 +1 @@ +Fixed SSL transport test failures on Python 3.10.22+, 3.11.17+, 3.12.15+, 3.13.16+, and 3.14.8+. Index: urllib3-1.26.20/test/test_ssltransport.py =================================================================== --- urllib3-1.26.20.orig/test/test_ssltransport.py +++ urllib3-1.26.20/test/test_ssltransport.py @@ -109,7 +109,7 @@ class SingleTLSLayerTestCase(SocketDummy context = ssl.create_default_context() sock.close() with pytest.raises(OSError): - SSLTransport(sock, context) + SSLTransport(sock, context, server_hostname="localhost") @pytest.mark.timeout(PER_TEST_TIMEOUT) def test_close_after_handshake(self):
