Security fix for CVE-2026-44431 and CVE-2026-44432

- Resolves: RHEL-184902
- Resolves: RHEL-185127
This commit is contained in:
Tomáš Hrnčiar 2026-06-02 14:32:52 +02:00
parent 03ca9de185
commit 69d5fd3b54
3 changed files with 324 additions and 1 deletions

158
CVE-2026-44431.patch Normal file
View File

@ -0,0 +1,158 @@
From 5ec0de499b9166ca71c65ab04f2a7e4eb0d66fcc Mon Sep 17 00:00:00 2001
From: Illia Volochii <illia.volochii@gmail.com>
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 <copilot@github.com>
---------
Co-authored-by: Copilot <copilot@github.com>
---
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"

162
CVE-2026-44432.patch Normal file
View File

@ -0,0 +1,162 @@
From 2bdcc44d1e163fb5cc48a8662425e35e15adfe6a Mon Sep 17 00:00:00 2001
From: Illia Volochii <illia.volochii@gmail.com>
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 <https://pypi.org/project/brotli/>`__ library.
+
+See `GHSA-mf9v-mfxr-j63j <https://github.com/urllib3/urllib3/security/advisories/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()

View File

@ -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.