From 43a6922654f62cb5468c532c2d5147aecfb81d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Hrn=C4=8Diar?= Date: Tue, 2 Jun 2026 14:32:52 +0200 Subject: [PATCH] Security fix for CVE-2026-44431 and CVE-2026-44432 - Resolves: RHEL-184903 - Resolves: RHEL-185128 --- CVE-2026-44431.patch | 158 +++++++++++++++++++++++++++++++++++++++ CVE-2026-44432.patch | 162 ++++++++++++++++++++++++++++++++++++++++ python3.14-urllib3.spec | 5 +- 3 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 CVE-2026-44431.patch create mode 100644 CVE-2026-44432.patch diff --git a/CVE-2026-44431.patch b/CVE-2026-44431.patch new file mode 100644 index 0000000..f383c7b --- /dev/null +++ b/CVE-2026-44431.patch @@ -0,0 +1,158 @@ +From 5ec0de499b9166ca71c65ab04f2a7e4eb0d66fcc Mon Sep 17 00:00:00 2001 +From: Illia Volochii +Date: Thu, 7 May 2026 18:40:31 +0300 +Subject: [PATCH] Merge commit from fork + +* Remove sensitive headers in proxy pools too + +* Add a changelog entry + +* Check retries history in tests + +Co-authored-by: Copilot + +--------- + +Co-authored-by: Copilot +--- + changelog/GHSA-qccp-gfcp-xxvc.bugfix.rst | 3 + + dummyserver/asgi_proxy.py | 1 + + src/urllib3/connectionpool.py | 12 ++++ + .../test_proxy_poolmanager.py | 72 +++++++++++++++++++ + 4 files changed, 88 insertions(+) + create mode 100644 changelog/GHSA-qccp-gfcp-xxvc.bugfix.rst + +diff --git a/changelog/GHSA-qccp-gfcp-xxvc.bugfix.rst b/changelog/GHSA-qccp-gfcp-xxvc.bugfix.rst +new file mode 100644 +index 0000000000..bac765ea28 +--- /dev/null ++++ b/changelog/GHSA-qccp-gfcp-xxvc.bugfix.rst +@@ -0,0 +1,3 @@ ++Fixed HTTP pools created using ``ProxyManager.connection_from_url`` to strip ++sensitive headers specified in ``Retry.remove_headers_on_redirect`` when ++redirecting to a different host. +diff --git a/dummyserver/asgi_proxy.py b/dummyserver/asgi_proxy.py +index 00c0a1b8b8..3ff1867380 100755 +--- a/dummyserver/asgi_proxy.py ++++ b/dummyserver/asgi_proxy.py +@@ -56,6 +56,7 @@ async def absolute_uri( + client_response = await client.request( + method=scope["method"], + url=scope["path"], ++ params=scope["query_string"].decode(), + headers=list(scope["headers"]), + content=await _read_body(receive), + ) +diff --git a/src/urllib3/connectionpool.py b/src/urllib3/connectionpool.py +index 9b66218bf1..70fbc5e725 100644 +--- a/src/urllib3/connectionpool.py ++++ b/src/urllib3/connectionpool.py +@@ -897,6 +897,18 @@ def urlopen( # type: ignore[override] + body = None + headers = HTTPHeaderDict(headers)._prepare_for_method_change() + ++ # Strip headers marked as unsafe to forward to the redirected location. ++ # Check remove_headers_on_redirect to avoid a potential network call within ++ # self.is_same_host() which may use socket.gethostbyname() in the future. ++ if retries.remove_headers_on_redirect and not self.is_same_host( ++ redirect_location ++ ): ++ new_headers = headers.copy() # type: ignore[union-attr] ++ for header in headers: ++ if header.lower() in retries.remove_headers_on_redirect: ++ new_headers.pop(header, None) ++ headers = new_headers ++ + try: + retries = retries.increment(method, url, response=response, _pool=self) + except MaxRetryError: +diff --git a/test/with_dummyserver/test_proxy_poolmanager.py b/test/with_dummyserver/test_proxy_poolmanager.py +index 4a932c0de4..6921bc6d22 100644 +--- a/test/with_dummyserver/test_proxy_poolmanager.py ++++ b/test/with_dummyserver/test_proxy_poolmanager.py +@@ -37,6 +37,7 @@ + SSLError, + ) + from urllib3.poolmanager import ProxyManager, proxy_from_url ++from urllib3.util.retry import RequestHistory + from urllib3.util.ssl_ import create_urllib3_context + from urllib3.util.timeout import Timeout + +@@ -300,6 +301,77 @@ def test_cross_host_redirect(self) -> None: + assert r._pool is not None + assert r._pool.host != self.http_host_alt + ++ _sensitive_headers = { ++ "Authorization": "foo", ++ "Proxy-Authorization": "bar", ++ "Cookie": "foo=bar", ++ } ++ ++ @pytest.mark.parametrize( ++ "sensitive_headers", ++ (_sensitive_headers, {k.lower(): v for k, v in _sensitive_headers.items()}), ++ ids=("capitalized", "lowercase"), ++ ) ++ def test_cross_host_redirect_remove_headers_via_proxy_manager( ++ self, sensitive_headers: dict[str, str] ++ ) -> None: ++ headers_url = f"{self.http_url_alt}/headers" ++ initial_url = f"{self.http_url}/redirect?target={headers_url}" ++ with proxy_from_url(self.proxy_url) as proxy_mgr: ++ r = proxy_mgr.request( ++ "GET", initial_url, headers=sensitive_headers, retries=1 ++ ) ++ assert r.status == 200 ++ assert r.retries is not None ++ assert r.retries.history == ( ++ RequestHistory( ++ method="GET", ++ url=initial_url, ++ error=None, ++ status=303, ++ redirect_location=headers_url, ++ ), ++ ) ++ data = r.json() ++ for header in sensitive_headers: ++ assert header not in data ++ ++ @pytest.mark.parametrize( ++ "sensitive_headers", ++ (_sensitive_headers, {k.lower(): v for k, v in _sensitive_headers.items()}), ++ ids=("capitalized", "lowercase"), ++ ) ++ def test_cross_host_redirect_remove_headers_via_pool( ++ self, sensitive_headers: dict[str, str] ++ ) -> None: ++ headers_url = f"{self.http_url_alt}/headers" ++ initial_url = f"{self.http_url}/redirect?target={headers_url}" ++ with proxy_from_url(self.proxy_url) as proxy_mgr: ++ pool = proxy_mgr.connection_from_url(self.http_url) ++ r = pool.urlopen( ++ "GET", ++ initial_url, ++ headers=sensitive_headers, ++ retries=1, ++ redirect=True, ++ assert_same_host=False, ++ preload_content=True, ++ ) ++ assert r.status == 200 ++ assert r.retries is not None ++ assert r.retries.history == ( ++ RequestHistory( ++ method="GET", ++ url=initial_url, ++ error=None, ++ status=303, ++ redirect_location=headers_url, ++ ), ++ ) ++ data = r.json() ++ for header in sensitive_headers: ++ assert header not in data ++ + def test_cross_protocol_redirect(self) -> None: + with proxy_from_url(self.proxy_url, ca_certs=DEFAULT_CA) as http: + cross_protocol_location = f"{self.https_url}/echo?a=b" diff --git a/CVE-2026-44432.patch b/CVE-2026-44432.patch new file mode 100644 index 0000000..05b6dff --- /dev/null +++ b/CVE-2026-44432.patch @@ -0,0 +1,162 @@ +From 2bdcc44d1e163fb5cc48a8662425e35e15adfe6a Mon Sep 17 00:00:00 2001 +From: Illia Volochii +Date: Thu, 7 May 2026 18:39:03 +0300 +Subject: [PATCH] Merge commit from fork + +* Avoid any decoding in `HTTPResponse.drain_conn` + +* Add a comment + +* Simplify `drain_conn` + +* Add tests + +* Add additional checks to the test + +* Fix full decompression on the 2nd small read from response using Brotli + +* Add a changelog entry + +* Inverse the order in the changelog entry + +* Mention `stream` call +--- + changelog/GHSA-mf9v-mfxr-j63j.bugfix.rst | 7 +++++++ + src/urllib3/response.py | 17 +++++++++++------ + test/test_response.py | 24 +++++++++++++++++++++--- + test/with_dummyserver/test_connection.py | 19 +++++++++++++++++++ + 4 files changed, 58 insertions(+), 9 deletions(-) + create mode 100644 changelog/GHSA-mf9v-mfxr-j63j.bugfix.rst + +diff --git a/changelog/GHSA-mf9v-mfxr-j63j.bugfix.rst b/changelog/GHSA-mf9v-mfxr-j63j.bugfix.rst +new file mode 100644 +index 0000000000..ac70af825a +--- /dev/null ++++ b/changelog/GHSA-mf9v-mfxr-j63j.bugfix.rst +@@ -0,0 +1,7 @@ ++Fixed two high-severity security issues where decompression-bomb safeguards of the streaming API were bypassed: ++ ++ ++1. When ``HTTPResponse.drain_conn()`` was called after the response had been read and decompressed partially. ++2. During the second ``HTTPResponse.read(amt=N)`` or ``HTTPResponse.stream(amt=N)`` call when the response was decompressed using the official `Brotli `__ library. ++ ++See `GHSA-mf9v-mfxr-j63j `__ for details. +diff --git a/src/urllib3/response.py b/src/urllib3/response.py +index 521c31b282..e9246b75e3 100644 +--- a/src/urllib3/response.py ++++ b/src/urllib3/response.py +@@ -798,13 +798,14 @@ def drain_conn(self) -> None: + Unread data in the HTTPResponse connection blocks the connection from being released back to the pool. + """ + try: +- self.read( +- # Do not spend resources decoding the content unless +- # decoding has already been initiated. +- decode_content=self._has_decoded_content, +- ) ++ self._raw_read() + except (HTTPError, OSError, BaseSSLError, HTTPException): + pass ++ if self._has_decoded_content: ++ # `_raw_read` skips decompression, so we should clean up the ++ # decoder to avoid keeping unnecessary data in memory. ++ self._decoded_buffer = BytesQueueBuffer() ++ self._decoder = None + + @property + def data(self) -> bytes: +@@ -1091,7 +1092,11 @@ def read( + elif amt is not None: + cache_content = False + +- if self._decoder and self._decoder.has_unconsumed_tail: ++ if ( ++ self._decoder ++ and self._decoder.has_unconsumed_tail ++ and len(self._decoded_buffer) < amt ++ ): + decoded_data = self._decode( + b"", + decode_content, +diff --git a/test/test_response.py b/test/test_response.py +index c70262c04e..da521137bf 100644 +--- a/test/test_response.py ++++ b/test/test_response.py +@@ -858,22 +858,33 @@ def test_memory_usage_decode_with_max_length( + pytest.skip(f"Proper {request.node.callspec.id} decoder is not available") + + name, compressed_data = data +- limit = 1024 * 1024 # 1 MiB ++ limit1 = 1024 * 1024 # 1 MiB ++ # We test with two read calls because the second call may be ++ # able to use the internal buffer filled by the first call, and ++ # we want to ensure that full decompression is never triggered ++ # by the second call. The limit for the second call is lowered ++ # to make sure that the internal buffer is used for the Brotli ++ # case specifically https://github.com/google/brotli/issues/1396 ++ limit2 = 1024 # 1 KiB + if read_method in ("read_chunked", "stream"): + httplib_r = httplib.HTTPResponse(MockSock) # type: ignore[arg-type] ++ httplib_r.chunked = True ++ httplib_r.chunk_left = 1 + httplib_r.fp = MockChunkedEncodingResponse([compressed_data]) # type: ignore[assignment] + r = HTTPResponse( + httplib_r, + preload_content=False, + headers={"transfer-encoding": "chunked", "content-encoding": name}, + ) +- next(getattr(r, read_method)(amt=limit, decode_content=True)) ++ for limit in (limit1, limit2): ++ next(getattr(r, read_method)(amt=limit, decode_content=True)) + else: + fp = BytesIO(compressed_data) + r = HTTPResponse( + fp, headers={"content-encoding": name}, preload_content=False + ) +- getattr(r, read_method)(amt=limit, decode_content=True) ++ for limit in (limit1, limit2): ++ getattr(r, read_method)(amt=limit, decode_content=True) + + # Check that the internal decoded buffer is empty unless brotli + # is used. +@@ -883,6 +894,13 @@ def test_memory_usage_decode_with_max_length( + if name != "br" or brotli.__name__ == "brotlicffi": + assert len(r._decoded_buffer) == 0 + ++ # Check that memory usage is still within the limit while the ++ # connection is being drained, meaning that the call does not ++ # decompress the whole content. ++ r.drain_conn() ++ assert r._decoder is None ++ assert len(r._decoded_buffer) == 0 ++ + def test_multi_decoding_deflate_deflate(self) -> None: + data = zlib.compress(zlib.compress(b"foo")) + +diff --git a/test/with_dummyserver/test_connection.py b/test/with_dummyserver/test_connection.py +index b9c547c00f..940815dac2 100644 +--- a/test/with_dummyserver/test_connection.py ++++ b/test/with_dummyserver/test_connection.py +@@ -138,3 +138,22 @@ def test_invalid_tunnel_scheme(pool: HTTPConnectionPool) -> None: + str(e.value) + == "Invalid proxy scheme for tunneling: 'socks', must be either 'http' or 'https'" + ) ++ ++ ++def test_response_after_drain_conn(pool: HTTPConnectionPool) -> None: ++ """ ++ Test that a connection can be reused after calling `drain_conn` on ++ an unread response. ++ """ ++ conn = pool._get_conn() ++ ++ conn.request("GET", "/", preload_content=False) ++ response = conn.getresponse() ++ assert response.status == 200 ++ response.drain_conn() ++ ++ conn.request("GET", "/", preload_content=False) ++ response = conn.getresponse() ++ assert response.status == 200 ++ ++ conn.close() diff --git a/python3.14-urllib3.spec b/python3.14-urllib3.spec index d3a244d..139178d 100644 --- a/python3.14-urllib3.spec +++ b/python3.14-urllib3.spec @@ -37,6 +37,9 @@ Patch: Replace-hatchling-with-setuptools-build-backend.patch %global hypercorn_commit d1719f8c1570cbd8e6a3719ffdb14a4d72880abb Source1: %{hypercorn_url}/archive/%{hypercorn_commit}/hypercorn-%{hypercorn_commit}.tar.gz +Patch: CVE-2026-44431.patch +Patch: CVE-2026-44432.patch + BuildArch: noarch BuildRequires: python%{python3_pkgversion}-devel @@ -79,7 +82,7 @@ many critical features that are missing from the Python standard libraries: %prep -%autosetup -n urllib3-%{version} +%autosetup -p1 -n urllib3-%{version} %setup -q -n urllib3-%{version} -T -D -b 1 # Make sure that the RECENT_DATE value doesn't get too far behind what the current date is.