已开启
Upgrade to 2.7.0 #207
Upgrade to 2.7.0 #207
已开启
roygiteee创建于 7月6日
共 12 个文件变更+11-2142
@@ -1,278 +0,0 @@
1-From f05b1329126d5be6de501f9d1e3e36738bc08857 Mon Sep 17 00:00:00 2001
2-From: Illia Volochii <illia.volochii@gmail.com>
3-Date: Wed, 18 Jun 2025 16:25:01 +0300
4-Subject: [PATCH] Merge commit from fork
5- 
6-* Apply Quentin's suggestion
7- 
8-Co-authored-by: Quentin Pradet <quentin.pradet@gmail.com>
9- 
10-* Add tests for disabled redirects in the pool manager
11- 
12-* Add a possible fix for the issue with not raised `MaxRetryError`
13- 
14-* Make urllib3 handle redirects instead of JS when JSPI is used
15- 
16-* Fix info in the new comment
17- 
18-* State that redirects with XHR are not controlled by urllib3
19- 
20-* Remove excessive params from new test requests
21- 
22-* Add tests reaching max non-0 redirects
23- 
24-* Test redirects with Emscripten
25- 
26-* Fix `test_merge_pool_kwargs`
27- 
28-* Add a changelog entry
29- 
30-* Parametrize tests
31- 
32-* Drop a fix for Emscripten
33- 
34-* Apply Seth's suggestion to docs
35- 
36-Co-authored-by: Seth Michael Larson <sethmichaellarson@gmail.com>
37- 
38-* Use a minor release instead of the patch one
39- 
40-Reference:https://github.com/urllib3/urllib3/commit/f05b1329126d5be6de501f9d1e3e36738bc08857
41-Conflict:CHANGES.rst has not been modified because it is a low version
42-file
43----
44- docs/reference/contrib/emscripten.rst | 2 +-
45- dummyserver/app.py | 1 +
46- src/urllib3/poolmanager.py | 18 +++-
47- test/contrib/emscripten/test_emscripten.py | 16 ++++
48- test/test_poolmanager.py | 5 +-
49- test/with_dummyserver/test_poolmanager.py | 101 +++++++++++++++++++++
50- 6 files changed, 139 insertions(+), 4 deletions(-)
51- 
52-diff --git a/docs/reference/contrib/emscripten.rst b/docs/reference/contrib/emscripten.rst
53-index 99fb20f..a8f1cda 100644
54---- a/docs/reference/contrib/emscripten.rst
55-+++ b/docs/reference/contrib/emscripten.rst
56-@@ -65,7 +65,7 @@ Features which are usable with Emscripten support are:
57- * Timeouts
58- * Retries
59- * Streaming (with Web Workers and Cross-Origin Isolation)
60--* Redirects
61-+* Redirects (determined by browser/runtime, not restrictable with urllib3)
62- * Decompressing response bodies
63-
64- Features which don't work with Emscripten:
65-diff --git a/dummyserver/app.py b/dummyserver/app.py
66-index 97b1b23..0eeb93f 100644
67---- a/dummyserver/app.py
68-+++ b/dummyserver/app.py
69-@@ -227,6 +227,7 @@ async def encodingrequest() -> ResponseReturnValue:
70-
71-
72- @hypercorn_app.route("/redirect", methods=["GET", "POST", "PUT"])
73-+@pyodide_testing_app.route("/redirect", methods=["GET", "POST", "PUT"])
74- async def redirect() -> ResponseReturnValue:
75- "Perform a redirect to ``target``"
76- values = await request.values
77-diff --git a/src/urllib3/poolmanager.py b/src/urllib3/poolmanager.py
78-index 085d1db..5763fea 100644
79---- a/src/urllib3/poolmanager.py
80-+++ b/src/urllib3/poolmanager.py
81-@@ -203,6 +203,22 @@ class PoolManager(RequestMethods):
82- **connection_pool_kw: typing.Any,
83- ) -> None:
84- super().__init__(headers)
85-+ if "retries" in connection_pool_kw:
86-+ retries = connection_pool_kw["retries"]
87-+ if not isinstance(retries, Retry):
88-+ # When Retry is initialized, raise_on_redirect is based
89-+ # on a redirect boolean value.
90-+ # But requests made via a pool manager always set
91-+ # redirect to False, and raise_on_redirect always ends
92-+ # up being False consequently.
93-+ # Here we fix the issue by setting raise_on_redirect to
94-+ # a value needed by the pool manager without considering
95-+ # the redirect boolean.
96-+ raise_on_redirect = retries is not False
97-+ retries = Retry.from_int(retries, redirect=False)
98-+ retries.raise_on_redirect = raise_on_redirect
99-+ connection_pool_kw = connection_pool_kw.copy()
100-+ connection_pool_kw["retries"] = retries
101- self.connection_pool_kw = connection_pool_kw
102-
103- self.pools: RecentlyUsedContainer[PoolKey, HTTPConnectionPool]
104-@@ -456,7 +472,7 @@ class PoolManager(RequestMethods):
105- kw["body"] = None
106- kw["headers"] = HTTPHeaderDict(kw["headers"])._prepare_for_method_change()
107-
108-- retries = kw.get("retries")
109-+ retries = kw.get("retries", response.retries)
110- if not isinstance(retries, Retry):
111- retries = Retry.from_int(retries, redirect=redirect)
112-
113-diff --git a/test/contrib/emscripten/test_emscripten.py b/test/contrib/emscripten/test_emscripten.py
114-index 9317a09..5eaa674 100644
115---- a/test/contrib/emscripten/test_emscripten.py
116-+++ b/test/contrib/emscripten/test_emscripten.py
117-@@ -944,6 +944,22 @@ def test_retries(
118- pyodide_test(selenium_coverage, testserver_http.http_host, find_unused_port())
119-
120-
121-+def test_redirects(
122-+ selenium_coverage: typing.Any, testserver_http: PyodideServerInfo
123-+) -> None:
124-+ @run_in_pyodide # type: ignore[misc]
125-+ def pyodide_test(selenium_coverage: typing.Any, host: str, port: int) -> None:
126-+ from urllib3 import request
127-+
128-+ redirect_url = f"http://{host}:{port}/redirect"
129-+ response = request("GET", redirect_url)
130-+ assert response.status == 200
131-+
132-+ pyodide_test(
133-+ selenium_coverage, testserver_http.http_host, testserver_http.http_port
134-+ )
135-+
136-+
137- def test_insecure_requests_warning(
138- selenium_coverage: typing.Any, testserver_http: PyodideServerInfo
139- ) -> None:
140-diff --git a/test/test_poolmanager.py b/test/test_poolmanager.py
141-index ab5f203..b481a19 100644
142---- a/test/test_poolmanager.py
143-+++ b/test/test_poolmanager.py
144-@@ -379,9 +379,10 @@ class TestPoolManager:
145-
146- def test_merge_pool_kwargs(self) -> None:
147- """Assert _merge_pool_kwargs works in the happy case"""
148-- p = PoolManager(retries=100)
149-+ retries = retry.Retry(total=100)
150-+ p = PoolManager(retries=retries)
151- merged = p._merge_pool_kwargs({"new_key": "value"})
152-- assert {"retries": 100, "new_key": "value"} == merged
153-+ assert {"retries": retries, "new_key": "value"} == merged
154-
155- def test_merge_pool_kwargs_none(self) -> None:
156- """Assert false-y values to _merge_pool_kwargs result in defaults"""
157-diff --git a/test/with_dummyserver/test_poolmanager.py b/test/with_dummyserver/test_poolmanager.py
158-index af77241..7f163ab 100644
159---- a/test/with_dummyserver/test_poolmanager.py
160-+++ b/test/with_dummyserver/test_poolmanager.py
161-@@ -84,6 +84,89 @@ class TestPoolManager(HypercornDummyServerTestCase):
162- assert r.status == 200
163- assert r.data == b"Dummy server!"
164-
165-+ @pytest.mark.parametrize(
166-+ "retries",
167-+ (0, Retry(total=0), Retry(redirect=0), Retry(total=0, redirect=0)),
168-+ )
169-+ def test_redirects_disabled_for_pool_manager_with_0(
170-+ self, retries: typing.Literal[0] | Retry
171-+ ) -> None:
172-+ """
173-+ Check handling redirects when retries is set to 0 on the pool
174-+ manager.
175-+ """
176-+ with PoolManager(retries=retries) as http:
177-+ with pytest.raises(MaxRetryError):
178-+ http.request("GET", f"{self.base_url}/redirect")
179-+
180-+ # Setting redirect=True should not change the behavior.
181-+ with pytest.raises(MaxRetryError):
182-+ http.request("GET", f"{self.base_url}/redirect", redirect=True)
183-+
184-+ # Setting redirect=False should not make it follow the redirect,
185-+ # but MaxRetryError should not be raised.
186-+ response = http.request("GET", f"{self.base_url}/redirect", redirect=False)
187-+ assert response.status == 303
188-+
189-+ @pytest.mark.parametrize(
190-+ "retries",
191-+ (
192-+ False,
193-+ Retry(total=False),
194-+ Retry(redirect=False),
195-+ Retry(total=False, redirect=False),
196-+ ),
197-+ )
198-+ def test_redirects_disabled_for_pool_manager_with_false(
199-+ self, retries: typing.Literal[False] | Retry
200-+ ) -> None:
201-+ """
202-+ Check that setting retries set to False on the pool manager disables
203-+ raising MaxRetryError and redirect=True does not change the
204-+ behavior.
205-+ """
206-+ with PoolManager(retries=retries) as http:
207-+ response = http.request("GET", f"{self.base_url}/redirect")
208-+ assert response.status == 303
209-+
210-+ response = http.request("GET", f"{self.base_url}/redirect", redirect=True)
211-+ assert response.status == 303
212-+
213-+ response = http.request("GET", f"{self.base_url}/redirect", redirect=False)
214-+ assert response.status == 303
215-+
216-+ def test_redirects_disabled_for_individual_request(self) -> None:
217-+ """
218-+ Check handling redirects when they are meant to be disabled
219-+ on the request level.
220-+ """
221-+ with PoolManager() as http:
222-+ # Check when redirect is not passed.
223-+ with pytest.raises(MaxRetryError):
224-+ http.request("GET", f"{self.base_url}/redirect", retries=0)
225-+ response = http.request("GET", f"{self.base_url}/redirect", retries=False)
226-+ assert response.status == 303
227-+
228-+ # Check when redirect=True.
229-+ with pytest.raises(MaxRetryError):
230-+ http.request(
231-+ "GET", f"{self.base_url}/redirect", retries=0, redirect=True
232-+ )
233-+ response = http.request(
234-+ "GET", f"{self.base_url}/redirect", retries=False, redirect=True
235-+ )
236-+ assert response.status == 303
237-+
238-+ # Check when redirect=False.
239-+ response = http.request(
240-+ "GET", f"{self.base_url}/redirect", retries=0, redirect=False
241-+ )
242-+ assert response.status == 303
243-+ response = http.request(
244-+ "GET", f"{self.base_url}/redirect", retries=False, redirect=False
245-+ )
246-+ assert response.status == 303
247-+
248- def test_cross_host_redirect(self) -> None:
249- with PoolManager() as http:
250- cross_host_location = f"{self.base_url_alt}/echo?a=b"
251-@@ -138,6 +221,24 @@ class TestPoolManager(HypercornDummyServerTestCase):
252- pool = http.connection_from_host(self.host, self.port)
253- assert pool.num_connections == 1
254-
255-+ # Check when retries are configured for the pool manager.
256-+ with PoolManager(retries=1) as http:
257-+ with pytest.raises(MaxRetryError):
258-+ http.request(
259-+ "GET",
260-+ f"{self.base_url}/redirect",
261-+ fields={"target": f"/redirect?target={self.base_url}/"},
262-+ )
263-+
264-+ # Here we allow more retries for the request.
265-+ response = http.request(
266-+ "GET",
267-+ f"{self.base_url}/redirect",
268-+ fields={"target": f"/redirect?target={self.base_url}/"},
269-+ retries=2,
270-+ )
271-+ assert response.status == 200
272-+
273- def test_redirect_cross_host_remove_headers(self) -> None:
274- with PoolManager() as http:
275- r = http.request(
276---
277-2.33.0
278- 
@@ -1,52 +0,0 @@
1-From 7eb4a2aafe49a279c29b6d1f0ed0f42e9736194f Mon Sep 17 00:00:00 2001
2-From: Illia Volochii <illia.volochii@gmail.com>
3-Date: Wed, 18 Jun 2025 16:30:35 +0300
4-Subject: [PATCH] Merge commit from fork
5- 
6-Reference:https://github.com/urllib3/urllib3/commit/7eb4a2aafe49a279c29b6d1f0ed0f42e9736194f
7-Conflict:test_emscripten.py has not been modified because it has been deleted in spec file.CHANGES.rst and emscripten.rst has not been modified because there are low version files now.
8----
9- src/urllib3/contrib/emscripten/fetch.py | 20 ++++++++++++++++++++
10- 1 file changed, 20 insertions(+)
11- 
12-diff --git a/src/urllib3/contrib/emscripten/fetch.py b/src/urllib3/contrib/emscripten/fetch.py
13-index a514306..6695821 100644
14---- a/src/urllib3/contrib/emscripten/fetch.py
15-+++ b/src/urllib3/contrib/emscripten/fetch.py
16-@@ -573,6 +573,11 @@ def send_jspi_request(
17- "method": request.method,
18- "signal": js_abort_controller.signal,
19- }
20-+ # Node.js returns the whole response (unlike opaqueredirect in browsers),
21-+ # so urllib3 can set `redirect: manual` to control redirects itself.
22-+ # https://stackoverflow.com/a/78524615
23-+ if _is_node_js():
24-+ fetch_data["redirect"] = "manual"
25- # Call JavaScript fetch (async api, returns a promise)
26- fetcher_promise_js = js.fetch(request.url, _obj_from_dict(fetch_data))
27- # Now suspend WebAssembly until we resolve that promise
28-@@ -693,6 +698,21 @@ def has_jspi() -> bool:
29- return False
30-
31-
32-+def _is_node_js() -> bool:
33-+ """
34-+ Check if we are in Node.js.
35-+
36-+ :return: True if we are in Node.js.
37-+ :rtype: bool
38-+ """
39-+ return (
40-+ hasattr(js, "process")
41-+ and hasattr(js.process, "release")
42-+ # According to the Node.js documentation, the release name is always "node".
43-+ and js.process.release.name == "node"
44-+ )
45-+
46-+
47- def streaming_ready() -> bool | None:
48- if _fetcher:
49- return _fetcher.streaming_ready
50---
51-2.33.0
52- 
@@ -1,73 +0,0 @@
1-From 24d7b67eac89f94e11003424bcf0d8f7b72222a8 Mon Sep 17 00:00:00 2001
2-From: Illia Volochii <illia.volochii@gmail.com>
3-Date: Fri, 5 Dec 2025 16:41:33 +0200
4-Subject: [PATCH] Merge commit from fork
5- 
6-* Add a hard-coded limit for the decompression chain
7- 
8-* Reuse new list
9- 
10-Reference:github.com/urllib3/urllib3/commit/24d7b67e.patch
11-Conflict:no
12----
13- changelog/GHSA-gm62-xv2j-4w53.security.rst | 4 ++++
14- src/urllib3/response.py | 12 +++++++++++-
15- test/test_response.py | 10 ++++++++++
16- 3 files changed, 25 insertions(+), 1 deletion(-)
17- create mode 100644 changelog/GHSA-gm62-xv2j-4w53.security.rst
18- 
19-diff --git a/changelog/GHSA-gm62-xv2j-4w53.security.rst b/changelog/GHSA-gm62-xv2j-4w53.security.rst
20-new file mode 100644
21-index 0000000000..6646eaa3a6
22---- /dev/null
23-+++ b/changelog/GHSA-gm62-xv2j-4w53.security.rst
24-@@ -0,0 +1,4 @@
25-+Fixed a security issue where an attacker could compose an HTTP response with
26-+virtually unlimited links in the ``Content-Encoding`` header, potentially
27-+leading to a denial of service (DoS) attack by exhausting system resources
28-+during decoding. The number of allowed chained encodings is now limited to 5.
29-diff --git a/src/urllib3/response.py b/src/urllib3/response.py
30-index 4ba421369f..069f726cb8 100644
31---- a/src/urllib3/response.py
32-+++ b/src/urllib3/response.py
33-@@ -306,8 +306,18 @@ class MultiDecoder(ContentDecoder):
34- they were applied.
35- """
36-
37-+ # Maximum allowed number of chained HTTP encodings in the
38-+ # Content-Encoding header.
39-+ max_decode_links = 5
40-+
41- def __init__(self, modes: str) -> None:
42-- self._decoders = [_get_decoder(m.strip()) for m in modes.split(",")]
43-+ encodings = [m.strip() for m in modes.split(",")]
44-+ if len(encodings) > self.max_decode_links:
45-+ raise DecodeError(
46-+ "Too many content encodings in the chain: "
47-+ f"{len(encodings)} > {self.max_decode_links}"
48-+ )
49-+ self._decoders = [_get_decoder(e) for e in encodings]
50-
51- def flush(self) -> bytes:
52- return self._decoders[0].flush()
53-diff --git a/test/test_response.py b/test/test_response.py
54-index 9592fdd940..d824ae70b4 100644
55---- a/test/test_response.py
56-+++ b/test/test_response.py
57-@@ -847,6 +847,16 @@ def test_read_multi_decoding_deflate_deflate(self) -> None:
58- assert r.read(9 * 37) == b"foobarbaz" * 37
59- assert r.read() == b""
60-
61-+ def test_read_multi_decoding_too_many_links(self) -> None:
62-+ fp = BytesIO(b"foo")
63-+ with pytest.raises(
64-+ DecodeError, match="Too many content encodings in the chain: 6 > 5"
65-+ ):
66-+ HTTPResponse(
67-+ fp,
68-+ headers={"content-encoding": "gzip, deflate, br, zstd, gzip, deflate"},
69-+ )
70-+
71- def test_body_blob(self) -> None:
72- resp = HTTPResponse(b"foo")
73- assert resp.data == b"foo"
@@ -1,306 +0,0 @@
1-From c4b5917e911a90c8bf279448df8952a682294135 Mon Sep 17 00:00:00 2001
2-From: Seth Michael Larson <sethmichaellarson@gmail.com>
3-Date: Fri, 30 May 2025 02:05:31 -0500
4-Subject: [PATCH] Add support for the new `compression.zstd` module in Python
5- 3.14 (#3611)
6- 
7-* Add support for the new `compression.zstd` module in Python 3.14
8- 
9-* Add zstandard to default 'Accept-Encoding' header for stdlib, too
10- 
11-* Fix all type hint issues
12- 
13-Reference:https://github.com/urllib3/urllib3/commit/c4b5917e.patch
14-Conflict:doc not merged
15----
16- src/urllib3/response.py | 69 +++++++++++++++++++++++++------------
17- src/urllib3/util/request.py | 16 ++++++---
18- test/__init__.py | 18 +++++++---
19- test/test_response.py | 38 +++++++++-----------
20- 4 files changed, 89 insertions(+), 52 deletions(-)
21- 
22-diff --git a/src/urllib3/response.py b/src/urllib3/response.py
23-index 575e7ce..860f908 100644
24---- a/src/urllib3/response.py
25-+++ b/src/urllib3/response.py
26-@@ -26,23 +26,6 @@ try:
27- except ImportError:
28- brotli = None
29-
30--try:
31-- import zstandard as zstd
32--except (AttributeError, ImportError, ValueError): # Defensive:
33-- HAS_ZSTD = False
34--else:
35-- # The package 'zstandard' added the 'eof' property starting
36-- # in v0.18.0 which we require to ensure a complete and
37-- # valid zstd stream was fed into the ZstdDecoder.
38-- # See: https://github.com/urllib3/urllib3/pull/2624
39-- _zstd_version = tuple(
40-- map(int, re.search(r"^([0-9]+)\.([0-9]+)", zstd.__version__).groups()) # type: ignore[union-attr]
41-- )
42-- if _zstd_version < (0, 18): # Defensive:
43-- HAS_ZSTD = False
44-- else:
45-- HAS_ZSTD = True
46--
47- from . import util
48- from ._base_connection import _TYPE_BODY
49- from ._collections import HTTPHeaderDict
50-@@ -163,11 +146,15 @@ if brotli is not None:
51- return b""
52-
53-
54--if HAS_ZSTD:
55-+try:
56-+ # Python 3.14+
57-+ from compression import zstd # type: ignore[import-not-found] # noqa: F401
58-+
59-+ HAS_ZSTD = True
60-
61- class ZstdDecoder(ContentDecoder):
62- def __init__(self) -> None:
63-- self._obj = zstd.ZstdDecompressor().decompressobj()
64-+ self._obj = zstd.ZstdDecompressor()
65-
66- def decompress(self, data: bytes) -> bytes:
67- if not data:
68-@@ -175,15 +162,53 @@ if HAS_ZSTD:
69- data_parts = [self._obj.decompress(data)]
70- while self._obj.eof and self._obj.unused_data:
71- unused_data = self._obj.unused_data
72-- self._obj = zstd.ZstdDecompressor().decompressobj()
73-+ self._obj = zstd.ZstdDecompressor()
74- data_parts.append(self._obj.decompress(unused_data))
75- return b"".join(data_parts)
76-
77- def flush(self) -> bytes:
78-- ret = self._obj.flush() # note: this is a no-op
79- if not self._obj.eof:
80- raise DecodeError("Zstandard data is incomplete")
81-- return ret
82-+ return b""
83-+
84-+except ImportError:
85-+ try:
86-+ # Python 3.13 and earlier require the 'zstandard' module.
87-+ import zstandard as zstd
88-+
89-+ # The package 'zstandard' added the 'eof' property starting
90-+ # in v0.18.0 which we require to ensure a complete and
91-+ # valid zstd stream was fed into the ZstdDecoder.
92-+ # See: https://github.com/urllib3/urllib3/pull/2624
93-+ _zstd_version = tuple(
94-+ map(int, re.search(r"^([0-9]+)\.([0-9]+)", zstd.__version__).groups()) # type: ignore[union-attr]
95-+ )
96-+ if _zstd_version < (0, 18): # Defensive:
97-+ raise ImportError("zstandard module doesn't have eof")
98-+ except (AttributeError, ImportError, ValueError): # Defensive:
99-+ HAS_ZSTD = False
100-+ else:
101-+ HAS_ZSTD = True
102-+
103-+ class ZstdDecoder(ContentDecoder): # type: ignore[no-redef]
104-+ def __init__(self) -> None:
105-+ self._obj = zstd.ZstdDecompressor().decompressobj()
106-+
107-+ def decompress(self, data: bytes) -> bytes:
108-+ if not data:
109-+ return b""
110-+ data_parts = [self._obj.decompress(data)]
111-+ while self._obj.eof and self._obj.unused_data:
112-+ unused_data = self._obj.unused_data
113-+ self._obj = zstd.ZstdDecompressor().decompressobj()
114-+ data_parts.append(self._obj.decompress(unused_data))
115-+ return b"".join(data_parts)
116-+
117-+ def flush(self) -> bytes:
118-+ ret = self._obj.flush() # note: this is a no-op
119-+ if not self._obj.eof:
120-+ raise DecodeError("Zstandard data is incomplete")
121-+ return ret # type: ignore[no-any-return]
122-
123-
124- class MultiDecoder(ContentDecoder):
125-diff --git a/src/urllib3/util/request.py b/src/urllib3/util/request.py
126-index 94392a1..23605c5 100644
127---- a/src/urllib3/util/request.py
128-+++ b/src/urllib3/util/request.py
129-@@ -28,12 +28,20 @@ except ImportError:
130- pass
131- else:
132- ACCEPT_ENCODING += ",br"
133-+
134- try:
135-- import zstandard as _unused_module_zstd # noqa: F401
136--except ImportError:
137-- pass
138--else:
139-+ from compression import ( # type: ignore[import-not-found] # noqa: F401
140-+ zstd as _unused_module_zstd,
141-+ )
142-+
143- ACCEPT_ENCODING += ",zstd"
144-+except ImportError:
145-+ try:
146-+ import zstandard as _unused_module_zstd # noqa: F401
147-+
148-+ ACCEPT_ENCODING += ",zstd"
149-+ except ImportError:
150-+ pass
151-
152-
153- class _TYPE_FAILEDTELL(Enum):
154-diff --git a/test/__init__.py b/test/__init__.py
155-index b5eedc7..281e411 100644
156---- a/test/__init__.py
157-+++ b/test/__init__.py
158-@@ -26,9 +26,18 @@ except ImportError:
159- brotli = None
160-
161- try:
162-- import zstandard as _unused_module_zstd # noqa: F401
163-+ # Python 3.14
164-+ from compression import ( # type: ignore[import-not-found] # noqa: F401
165-+ zstd as _unused_module_zstd,
166-+ )
167- except ImportError:
168-- HAS_ZSTD = False
169-+ # Python 3.13 and earlier require the 'zstandard' module.
170-+ try:
171-+ import zstandard as _unused_module_zstd # noqa: F401
172-+ except ImportError:
173-+ HAS_ZSTD = False
174-+ else:
175-+ HAS_ZSTD = True
176- else:
177- HAS_ZSTD = True
178-
179-@@ -127,14 +136,15 @@ def notBrotli() -> typing.Callable[[_TestFuncT], _TestFuncT]:
180-
181- def onlyZstd() -> typing.Callable[[_TestFuncT], _TestFuncT]:
182- return pytest.mark.skipif(
183-- not HAS_ZSTD, reason="only run if a python-zstandard library is installed"
184-+ not HAS_ZSTD,
185-+ reason="only run if a python-zstandard library is installed or Python 3.14 and later",
186- )
187-
188-
189- def notZstd() -> typing.Callable[[_TestFuncT], _TestFuncT]:
190- return pytest.mark.skipif(
191- HAS_ZSTD,
192-- reason="only run if a python-zstandard library is not installed",
193-+ reason="only run if a python-zstandard library is not installed or Python 3.13 and earlier",
194- )
195-
196-
197-diff --git a/test/test_response.py b/test/test_response.py
198-index 53606e1..72aa1d4 100644
199---- a/test/test_response.py
200-+++ b/test/test_response.py
201-@@ -35,6 +35,14 @@ from urllib3.util.response import is_fp_closed
202- from urllib3.util.retry import RequestHistory, Retry
203-
204-
205-+def zstd_compress(data: bytes) -> bytes:
206-+ try:
207-+ from compression import zstd # type: ignore[import-not-found] # noqa: F401
208-+ except ImportError:
209-+ import zstandard as zstd
210-+ return zstd.compress(data) # type: ignore[no-any-return]
211-+
212-+
213- class TestBytesQueueBuffer:
214- def test_single_chunk(self) -> None:
215- buffer = BytesQueueBuffer()
216-@@ -411,9 +419,7 @@ class TestResponse:
217-
218- @onlyZstd()
219- def test_decode_zstd(self) -> None:
220-- import zstandard as zstd
221--
222-- data = zstd.compress(b"foo")
223-+ data = zstd_compress(b"foo")
224-
225- fp = BytesIO(data)
226- r = HTTPResponse(fp, headers={"content-encoding": "zstd"})
227-@@ -421,11 +427,9 @@ class TestResponse:
228-
229- @onlyZstd()
230- def test_decode_multiframe_zstd(self) -> None:
231-- import zstandard as zstd
232--
233- data = (
234- # Zstandard frame
235-- zstd.compress(b"foo")
236-+ zstd_compress(b"foo")
237- # skippable frame (must be ignored)
238- + bytes.fromhex(
239- "50 2A 4D 18" # Magic_Number (little-endian)
240-@@ -433,7 +437,7 @@ class TestResponse:
241- "00 00 00 00 00 00 00" # User_Data
242- )
243- # Zstandard frame
244-- + zstd.compress(b"bar")
245-+ + zstd_compress(b"bar")
246- )
247-
248- fp = BytesIO(data)
249-@@ -442,9 +446,7 @@ class TestResponse:
250-
251- @onlyZstd()
252- def test_chunked_decoding_zstd(self) -> None:
253-- import zstandard as zstd
254--
255-- data = zstd.compress(b"foobarbaz")
256-+ data = zstd_compress(b"foobarbaz")
257-
258- fp = BytesIO(data)
259- r = HTTPResponse(
260-@@ -475,9 +477,7 @@ class TestResponse:
261- @onlyZstd()
262- @pytest.mark.parametrize("data", decode_param_set)
263- def test_decode_zstd_incomplete_preload_content(self, data: bytes) -> None:
264-- import zstandard as zstd
265--
266-- data = zstd.compress(data)
267-+ data = zstd_compress(data)
268- fp = BytesIO(data[:-1])
269-
270- with pytest.raises(DecodeError):
271-@@ -486,9 +486,7 @@ class TestResponse:
272- @onlyZstd()
273- @pytest.mark.parametrize("data", decode_param_set)
274- def test_decode_zstd_incomplete_read(self, data: bytes) -> None:
275-- import zstandard as zstd
276--
277-- data = zstd.compress(data)
278-+ data = zstd_compress(data)
279- fp = BytesIO(data[:-1]) # shorten the data to trigger DecodeError
280-
281- # create response object without(!) reading/decoding the content
282-@@ -503,9 +501,7 @@ class TestResponse:
283- @onlyZstd()
284- @pytest.mark.parametrize("data", decode_param_set)
285- def test_decode_zstd_incomplete_read1(self, data: bytes) -> None:
286-- import zstandard as zstd
287--
288-- data = zstd.compress(data)
289-+ data = zstd_compress(data)
290- fp = BytesIO(data[:-1])
291-
292- r = HTTPResponse(
293-@@ -523,9 +519,7 @@ class TestResponse:
294- @onlyZstd()
295- @pytest.mark.parametrize("data", decode_param_set)
296- def test_decode_zstd_read1(self, data: bytes) -> None:
297-- import zstandard as zstd
298--
299-- encoded_data = zstd.compress(data)
300-+ encoded_data = zstd_compress(data)
301- fp = BytesIO(encoded_data)
302-
303- r = HTTPResponse(
304---
305-2.43.0
306- 
@@ -1,216 +0,0 @@
1-From 93e6ae22ab1b708ca859cfe7f6f219e8de20e015 Mon Sep 17 00:00:00 2001
2-From: Rogdham <3994389+Rogdham@users.noreply.github.com>
3-Date: Tue, 28 Oct 2025 11:11:15 +0100
4-Subject: [PATCH] Use backport.zstd instead of zstandard (#3697)
5- 
6-Reference:https://github.com/urllib3/urllib3/commit/93e6ae22ab.patch
7-Conflict:doc not merged
8----
9- src/urllib3/response.py | 50 ++++++-------------------------------
10- src/urllib3/util/request.py | 24 ++++++++----------
11- test/__init__.py | 20 ++++++---------
12- test/test_response.py | 11 ++++----
13- 4 files changed, 31 insertions(+), 74 deletions(-)
14- 
15-diff --git a/src/urllib3/response.py b/src/urllib3/response.py
16-index 2357876..1095da9 100644
17---- a/src/urllib3/response.py
18-+++ b/src/urllib3/response.py
19-@@ -4,7 +4,6 @@ import collections
20- import io
21- import json as _json
22- import logging
23--import re
24- import socket
25- import sys
26- import typing
27-@@ -239,9 +238,13 @@ if brotli is not None:
28-
29-
30- try:
31-- # Python 3.14+
32-- from compression import zstd # type: ignore[import-not-found] # noqa: F401
33--
34-+ if sys.version_info >= (3, 14):
35-+ from compression import zstd
36-+ else:
37-+ from backports import zstd
38-+except ImportError:
39-+ HAS_ZSTD = False
40-+else:
41- HAS_ZSTD = True
42-
43- class ZstdDecoder(ContentDecoder):
44-@@ -293,45 +296,6 @@ try:
45- raise DecodeError("Zstandard data is incomplete")
46- return b""
47-
48--except ImportError:
49-- try:
50-- # Python 3.13 and earlier require the 'zstandard' module.
51-- import zstandard as zstd
52--
53-- # The package 'zstandard' added the 'eof' property starting
54-- # in v0.18.0 which we require to ensure a complete and
55-- # valid zstd stream was fed into the ZstdDecoder.
56-- # See: https://github.com/urllib3/urllib3/pull/2624
57-- _zstd_version = tuple(
58-- map(int, re.search(r"^([0-9]+)\.([0-9]+)", zstd.__version__).groups()) # type: ignore[union-attr]
59-- )
60-- if _zstd_version < (0, 18): # Defensive:
61-- raise ImportError("zstandard module doesn't have eof")
62-- except (AttributeError, ImportError, ValueError): # Defensive:
63-- HAS_ZSTD = False
64-- else:
65-- HAS_ZSTD = True
66--
67-- class ZstdDecoder(ContentDecoder): # type: ignore[no-redef]
68-- def __init__(self) -> None:
69-- self._obj = zstd.ZstdDecompressor().decompressobj()
70--
71-- def decompress(self, data: bytes) -> bytes:
72-- if not data:
73-- return b""
74-- data_parts = [self._obj.decompress(data)]
75-- while self._obj.eof and self._obj.unused_data:
76-- unused_data = self._obj.unused_data
77-- self._obj = zstd.ZstdDecompressor().decompressobj()
78-- data_parts.append(self._obj.decompress(unused_data))
79-- return b"".join(data_parts)
80--
81-- def flush(self) -> bytes:
82-- ret = self._obj.flush() # note: this is a no-op
83-- if not self._obj.eof:
84-- raise DecodeError("Zstandard data is incomplete")
85-- return ret # type: ignore[no-any-return]
86--
87-
88- class MultiDecoder(ContentDecoder):
89- """
90-diff --git a/src/urllib3/util/request.py b/src/urllib3/util/request.py
91-index 23605c5..8295597 100644
92---- a/src/urllib3/util/request.py
93-+++ b/src/urllib3/util/request.py
94-@@ -1,6 +1,7 @@
95- from __future__ import annotations
96-
97- import io
98-+import sys
99- import typing
100- from base64 import b64encode
101- from enum import Enum
102-@@ -30,18 +31,14 @@ else:
103- ACCEPT_ENCODING += ",br"
104-
105- try:
106-- from compression import ( # type: ignore[import-not-found] # noqa: F401
107-- zstd as _unused_module_zstd,
108-- )
109--
110-- ACCEPT_ENCODING += ",zstd"
111-+ if sys.version_info >= (3, 14):
112-+ from compression import zstd as _unused_module_zstd # noqa: F401
113-+ else:
114-+ from backports import zstd as _unused_module_zstd # noqa: F401
115- except ImportError:
116-- try:
117-- import zstandard as _unused_module_zstd # noqa: F401
118--
119-- ACCEPT_ENCODING += ",zstd"
120-- except ImportError:
121-- pass
122-+ pass
123-+else:
124-+ ACCEPT_ENCODING += ",zstd"
125-
126-
127- class _TYPE_FAILEDTELL(Enum):
128-@@ -77,8 +74,9 @@ def make_headers(
129- :param accept_encoding:
130- Can be a boolean, list, or string.
131- ``True`` translates to 'gzip,deflate'. If the dependencies for
132-- Brotli (either the ``brotli`` or ``brotlicffi`` package) and/or Zstandard
133-- (the ``zstandard`` package) algorithms are installed, then their encodings are
134-+ Brotli (either the ``brotli`` or ``brotlicffi`` package) and/or
135-+ Zstandard (the ``backports.zstd`` package for Python before 3.14)
136-+ algorithms are installed, then their encodings are
137- included in the string ('br' and 'zstd', respectively).
138- List will get joined by comma.
139- String will be used as provided.
140-diff --git a/test/__init__.py b/test/__init__.py
141-index 281e411..d545d8e 100644
142---- a/test/__init__.py
143-+++ b/test/__init__.py
144-@@ -26,18 +26,12 @@ except ImportError:
145- brotli = None
146-
147- try:
148-- # Python 3.14
149-- from compression import ( # type: ignore[import-not-found] # noqa: F401
150-- zstd as _unused_module_zstd,
151-- )
152--except ImportError:
153-- # Python 3.13 and earlier require the 'zstandard' module.
154-- try:
155-- import zstandard as _unused_module_zstd # noqa: F401
156-- except ImportError:
157-- HAS_ZSTD = False
158-+ if sys.version_info >= (3, 14):
159-+ from compression import zstd as _unused_module_zstd # noqa: F401
160- else:
161-- HAS_ZSTD = True
162-+ from backports import zstd as _unused_module_zstd # noqa: F401
163-+except ImportError:
164-+ HAS_ZSTD = False
165- else:
166- HAS_ZSTD = True
167-
168-@@ -137,14 +131,14 @@ def notBrotli() -> typing.Callable[[_TestFuncT], _TestFuncT]:
169- def onlyZstd() -> typing.Callable[[_TestFuncT], _TestFuncT]:
170- return pytest.mark.skipif(
171- not HAS_ZSTD,
172-- reason="only run if a python-zstandard library is installed or Python 3.14 and later",
173-+ reason="only run if backports.zstd library is installed or Python 3.14 and later",
174- )
175-
176-
177- def notZstd() -> typing.Callable[[_TestFuncT], _TestFuncT]:
178- return pytest.mark.skipif(
179- HAS_ZSTD,
180-- reason="only run if a python-zstandard library is not installed or Python 3.13 and earlier",
181-+ reason="only run if backports.zstd library is not installed and Python 3.13 and earlier",
182- )
183-
184-
185-diff --git a/test/test_response.py b/test/test_response.py
186-index fa2c62b..835287b 100644
187---- a/test/test_response.py
188-+++ b/test/test_response.py
189-@@ -5,6 +5,7 @@ import gzip
190- import http.client as httplib
191- import socket
192- import ssl
193-+import sys
194- import typing
195- import zlib
196- from base64 import b64decode
197-@@ -37,11 +38,11 @@ from urllib3.util.retry import RequestHistory, Retry
198-
199-
200- def zstd_compress(data: bytes) -> bytes:
201-- try:
202-- from compression import zstd # type: ignore[import-not-found] # noqa: F401
203-- except ImportError:
204-- import zstandard as zstd
205-- return zstd.compress(data) # type: ignore[no-any-return]
206-+ if sys.version_info >= (3, 14):
207-+ from compression import zstd
208-+ else:
209-+ from backports import zstd
210-+ return zstd.compress(data)
211-
212- def deflate2_compress(data: bytes) -> bytes:
213- compressor = zlib.compressobj(6, zlib.DEFLATED, -zlib.MAX_WBITS)
214---
215-2.43.0
216- 
@@ -1,37 +0,0 @@
1-From 8864ac407bba8607950025e0979c4c69bc7abc7b Mon Sep 17 00:00:00 2001
2-From: Illia Volochii <illia.volochii@gmail.com>
3-Date: Wed, 7 Jan 2026 18:07:30 +0200
4-Subject: [PATCH] Merge commit from fork
5- 
6-* Stop decoding response content during redirects needlessly
7- 
8-* Rename the new query parameter
9- 
10-* Add a changelog entry
11- 
12-Reference:https://github.com/urllib3/urllib3/commit/8864ac407.patch
13-Conflict:doc and test not merged
14----
15- src/urllib3/response.py | 6 +++++-
16- 1 file changed, 5 insertions(+), 1 deletion(-)
17- 
18-diff --git a/src/urllib3/response.py b/src/urllib3/response.py
19-index 1095da9..aa64150 100644
20---- a/src/urllib3/response.py
21-+++ b/src/urllib3/response.py
22-@@ -806,7 +806,11 @@ class HTTPResponse(BaseHTTPResponse):
23- Unread data in the HTTPResponse connection blocks the connection from being released back to the pool.
24- """
25- try:
26-- self.read()
27-+ self.read(
28-+ # Do not spend resources decoding the content unless
29-+ # decoding has already been initiated.
30-+ decode_content=self._has_decoded_content,
31-+ )
32- except (HTTPError, OSError, BaseSSLError, HTTPException):
33- pass
34-
35---
36-2.43.0
37- 
@@ -1,158 +0,0 @@
1-From 5ec0de499b9166ca71c65ab04f2a7e4eb0d66fcc Mon Sep 17 00:00:00 2001
2-From: Illia Volochii <illia.volochii@gmail.com>
3-Date: Thu, 7 May 2026 18:40:31 +0300
4-Subject: [PATCH] Merge commit from fork
5- 
6-* Remove sensitive headers in proxy pools too
7- 
8-* Add a changelog entry
9- 
10-* Check retries history in tests
11- 
12-Co-authored-by: Copilot <copilot@github.com>
13- 
14----------
15- 
16-Co-authored-by: Copilot <copilot@github.com>
17----
18- changelog/GHSA-qccp-gfcp-xxvc.bugfix.rst | 3 +
19- dummyserver/asgi_proxy.py | 1 +
20- src/urllib3/connectionpool.py | 12 ++++
21- .../test_proxy_poolmanager.py | 72 +++++++++++++++++++
22- 4 files changed, 88 insertions(+)
23- create mode 100644 changelog/GHSA-qccp-gfcp-xxvc.bugfix.rst
24- 
25-diff --git a/changelog/GHSA-qccp-gfcp-xxvc.bugfix.rst b/changelog/GHSA-qccp-gfcp-xxvc.bugfix.rst
26-new file mode 100644
27-index 0000000000..bac765ea28
28---- /dev/null
29-+++ b/changelog/GHSA-qccp-gfcp-xxvc.bugfix.rst
30-@@ -0,0 +1,3 @@
31-+Fixed HTTP pools created using ``ProxyManager.connection_from_url`` to strip
32-+sensitive headers specified in ``Retry.remove_headers_on_redirect`` when
33-+redirecting to a different host.
34-diff --git a/dummyserver/asgi_proxy.py b/dummyserver/asgi_proxy.py
35-index 00c0a1b8b8..3ff1867380 100755
36---- a/dummyserver/asgi_proxy.py
37-+++ b/dummyserver/asgi_proxy.py
38-@@ -56,6 +56,7 @@ async def absolute_uri(
39- client_response = await client.request(
40- method=scope["method"],
41- url=scope["path"],
42-+ params=scope["query_string"].decode(),
43- headers=list(scope["headers"]),
44- content=await _read_body(receive),
45- )
46-diff --git a/src/urllib3/connectionpool.py b/src/urllib3/connectionpool.py
47-index 9b66218bf1..70fbc5e725 100644
48---- a/src/urllib3/connectionpool.py
49-+++ b/src/urllib3/connectionpool.py
50-@@ -897,6 +897,18 @@ def urlopen( # type: ignore[override]
51- body = None
52- headers = HTTPHeaderDict(headers)._prepare_for_method_change()
53-
54-+ # Strip headers marked as unsafe to forward to the redirected location.
55-+ # Check remove_headers_on_redirect to avoid a potential network call within
56-+ # self.is_same_host() which may use socket.gethostbyname() in the future.
57-+ if retries.remove_headers_on_redirect and not self.is_same_host(
58-+ redirect_location
59-+ ):
60-+ new_headers = headers.copy() # type: ignore[union-attr]
61-+ for header in headers:
62-+ if header.lower() in retries.remove_headers_on_redirect:
63-+ new_headers.pop(header, None)
64-+ headers = new_headers
65-+
66- try:
67- retries = retries.increment(method, url, response=response, _pool=self)
68- except MaxRetryError:
69-diff --git a/test/with_dummyserver/test_proxy_poolmanager.py b/test/with_dummyserver/test_proxy_poolmanager.py
70-index 4a932c0de4..6921bc6d22 100644
71---- a/test/with_dummyserver/test_proxy_poolmanager.py
72-+++ b/test/with_dummyserver/test_proxy_poolmanager.py
73-@@ -37,6 +37,7 @@
74- SSLError,
75- )
76- from urllib3.poolmanager import ProxyManager, proxy_from_url
77-+from urllib3.util.retry import RequestHistory
78- from urllib3.util.ssl_ import create_urllib3_context
79- from urllib3.util.timeout import Timeout
80-
81-@@ -300,6 +301,77 @@ def test_cross_host_redirect(self) -> None:
82- assert r._pool is not None
83- assert r._pool.host != self.http_host_alt
84-
85-+ _sensitive_headers = {
86-+ "Authorization": "foo",
87-+ "Proxy-Authorization": "bar",
88-+ "Cookie": "foo=bar",
89-+ }
90-+
91-+ @pytest.mark.parametrize(
92-+ "sensitive_headers",
93-+ (_sensitive_headers, {k.lower(): v for k, v in _sensitive_headers.items()}),
94-+ ids=("capitalized", "lowercase"),
95-+ )
96-+ def test_cross_host_redirect_remove_headers_via_proxy_manager(
97-+ self, sensitive_headers: dict[str, str]
98-+ ) -> None:
99-+ headers_url = f"{self.http_url_alt}/headers"
100-+ initial_url = f"{self.http_url}/redirect?target={headers_url}"
101-+ with proxy_from_url(self.proxy_url) as proxy_mgr:
102-+ r = proxy_mgr.request(
103-+ "GET", initial_url, headers=sensitive_headers, retries=1
104-+ )
105-+ assert r.status == 200
106-+ assert r.retries is not None
107-+ assert r.retries.history == (
108-+ RequestHistory(
109-+ method="GET",
110-+ url=initial_url,
111-+ error=None,
112-+ status=303,
113-+ redirect_location=headers_url,
114-+ ),
115-+ )
116-+ data = r.json()
117-+ for header in sensitive_headers:
118-+ assert header not in data
119-+
120-+ @pytest.mark.parametrize(
121-+ "sensitive_headers",
122-+ (_sensitive_headers, {k.lower(): v for k, v in _sensitive_headers.items()}),
123-+ ids=("capitalized", "lowercase"),
124-+ )
125-+ def test_cross_host_redirect_remove_headers_via_pool(
126-+ self, sensitive_headers: dict[str, str]
127-+ ) -> None:
128-+ headers_url = f"{self.http_url_alt}/headers"
129-+ initial_url = f"{self.http_url}/redirect?target={headers_url}"
130-+ with proxy_from_url(self.proxy_url) as proxy_mgr:
131-+ pool = proxy_mgr.connection_from_url(self.http_url)
132-+ r = pool.urlopen(
133-+ "GET",
134-+ initial_url,
135-+ headers=sensitive_headers,
136-+ retries=1,
137-+ redirect=True,
138-+ assert_same_host=False,
139-+ preload_content=True,
140-+ )
141-+ assert r.status == 200
142-+ assert r.retries is not None
143-+ assert r.retries.history == (
144-+ RequestHistory(
145-+ method="GET",
146-+ url=initial_url,
147-+ error=None,
148-+ status=303,
149-+ redirect_location=headers_url,
150-+ ),
151-+ )
152-+ data = r.json()
153-+ for header in sensitive_headers:
154-+ assert header not in data
155-+
156- def test_cross_protocol_redirect(self) -> None:
157- with proxy_from_url(self.proxy_url, ca_certs=DEFAULT_CA) as http:
158- cross_protocol_location = f"{self.https_url}/echo?a=b"
@@ -1,162 +0,0 @@
1-From 2bdcc44d1e163fb5cc48a8662425e35e15adfe6a Mon Sep 17 00:00:00 2001
2-From: Illia Volochii <illia.volochii@gmail.com>
3-Date: Thu, 7 May 2026 18:39:03 +0300
4-Subject: [PATCH] Merge commit from fork
5- 
6-* Avoid any decoding in `HTTPResponse.drain_conn`
7- 
8-* Add a comment
9- 
10-* Simplify `drain_conn`
11- 
12-* Add tests
13- 
14-* Add additional checks to the test
15- 
16-* Fix full decompression on the 2nd small read from response using Brotli
17- 
18-* Add a changelog entry
19- 
20-* Inverse the order in the changelog entry
21- 
22-* Mention `stream` call
23----
24- changelog/GHSA-mf9v-mfxr-j63j.bugfix.rst | 7 +++++++
25- src/urllib3/response.py | 17 +++++++++++------
26- test/test_response.py | 24 +++++++++++++++++++++---
27- test/with_dummyserver/test_connection.py | 19 +++++++++++++++++++
28- 4 files changed, 58 insertions(+), 9 deletions(-)
29- create mode 100644 changelog/GHSA-mf9v-mfxr-j63j.bugfix.rst
30- 
31-diff --git a/changelog/GHSA-mf9v-mfxr-j63j.bugfix.rst b/changelog/GHSA-mf9v-mfxr-j63j.bugfix.rst
32-new file mode 100644
33-index 0000000000..ac70af825a
34---- /dev/null
35-+++ b/changelog/GHSA-mf9v-mfxr-j63j.bugfix.rst
36-@@ -0,0 +1,7 @@
37-+Fixed two high-severity security issues where decompression-bomb safeguards of the streaming API were bypassed:
38-+
39-+
40-+1. When ``HTTPResponse.drain_conn()`` was called after the response had been read and decompressed partially.
41-+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.
42-+
43-+See `GHSA-mf9v-mfxr-j63j <https://github.com/urllib3/urllib3/security/advisories/GHSA-mf9v-mfxr-j63j>`__ for details.
44-diff --git a/src/urllib3/response.py b/src/urllib3/response.py
45-index 521c31b282..e9246b75e3 100644
46---- a/src/urllib3/response.py
47-+++ b/src/urllib3/response.py
48-@@ -798,13 +798,14 @@ def drain_conn(self) -> None:
49- Unread data in the HTTPResponse connection blocks the connection from being released back to the pool.
50- """
51- try:
52-- self.read(
53-- # Do not spend resources decoding the content unless
54-- # decoding has already been initiated.
55-- decode_content=self._has_decoded_content,
56-- )
57-+ self._raw_read()
58- except (HTTPError, OSError, BaseSSLError, HTTPException):
59- pass
60-+ if self._has_decoded_content:
61-+ # `_raw_read` skips decompression, so we should clean up the
62-+ # decoder to avoid keeping unnecessary data in memory.
63-+ self._decoded_buffer = BytesQueueBuffer()
64-+ self._decoder = None
65- 
66- @property
67- def data(self) -> bytes:
68-@@ -1091,7 +1092,11 @@ def read(
69- elif amt is not None:
70- cache_content = False
71- 
72-- if self._decoder and self._decoder.has_unconsumed_tail:
73-+ if (
74-+ self._decoder
75-+ and self._decoder.has_unconsumed_tail
76-+ and len(self._decoded_buffer) < amt
77-+ ):
78- decoded_data = self._decode(
79- b"",
80- decode_content,
81-diff --git a/test/test_response.py b/test/test_response.py
82-index c70262c04e..da521137bf 100644
83---- a/test/test_response.py
84-+++ b/test/test_response.py
85-@@ -858,22 +858,33 @@ def test_memory_usage_decode_with_max_length(
86- pytest.skip(f"Proper {request.node.callspec.id} decoder is not available")
87- 
88- name, compressed_data = data
89-- limit = 1024 * 1024 # 1 MiB
90-+ limit1 = 1024 * 1024 # 1 MiB
91-+ # We test with two read calls because the second call may be
92-+ # able to use the internal buffer filled by the first call, and
93-+ # we want to ensure that full decompression is never triggered
94-+ # by the second call. The limit for the second call is lowered
95-+ # to make sure that the internal buffer is used for the Brotli
96-+ # case specifically https://github.com/google/brotli/issues/1396
97-+ limit2 = 1024 # 1 KiB
98- if read_method in ("read_chunked", "stream"):
99- httplib_r = httplib.HTTPResponse(MockSock) # type: ignore[arg-type]
100-+ httplib_r.chunked = True
101-+ httplib_r.chunk_left = 1
102- httplib_r.fp = MockChunkedEncodingResponse([compressed_data]) # type: ignore[assignment]
103- r = HTTPResponse(
104- httplib_r,
105- preload_content=False,
106- headers={"transfer-encoding": "chunked", "content-encoding": name},
107- )
108-- next(getattr(r, read_method)(amt=limit, decode_content=True))
109-+ for limit in (limit1, limit2):
110-+ next(getattr(r, read_method)(amt=limit, decode_content=True))
111- else:
112- fp = BytesIO(compressed_data)
113- r = HTTPResponse(
114- fp, headers={"content-encoding": name}, preload_content=False
115- )
116-- getattr(r, read_method)(amt=limit, decode_content=True)
117-+ for limit in (limit1, limit2):
118-+ getattr(r, read_method)(amt=limit, decode_content=True)
119- 
120- # Check that the internal decoded buffer is empty unless brotli
121- # is used.
122-@@ -883,6 +894,13 @@ def test_memory_usage_decode_with_max_length(
123- if name != "br" or brotli.__name__ == "brotlicffi":
124- assert len(r._decoded_buffer) == 0
125- 
126-+ # Check that memory usage is still within the limit while the
127-+ # connection is being drained, meaning that the call does not
128-+ # decompress the whole content.
129-+ r.drain_conn()
130-+ assert r._decoder is None
131-+ assert len(r._decoded_buffer) == 0
132-+
133- def test_multi_decoding_deflate_deflate(self) -> None:
134- data = zlib.compress(zlib.compress(b"foo"))
135- 
136-diff --git a/test/with_dummyserver/test_connection.py b/test/with_dummyserver/test_connection.py
137-index b9c547c00f..940815dac2 100644
138---- a/test/with_dummyserver/test_connection.py
139-+++ b/test/with_dummyserver/test_connection.py
140-@@ -138,3 +138,22 @@ def test_invalid_tunnel_scheme(pool: HTTPConnectionPool) -> None:
141- str(e.value)
142- == "Invalid proxy scheme for tunneling: 'socks', must be either 'http' or 'https'"
143- )
144-+
145-+
146-+def test_response_after_drain_conn(pool: HTTPConnectionPool) -> None:
147-+ """
148-+ Test that a connection can be reused after calling `drain_conn` on
149-+ an unread response.
150-+ """
151-+ conn = pool._get_conn()
152-+
153-+ conn.request("GET", "/", preload_content=False)
154-+ response = conn.getresponse()
155-+ assert response.status == 200
156-+ response.drain_conn()
157-+
158-+ conn.request("GET", "/", preload_content=False)
159-+ response = conn.getresponse()
160-+ assert response.status == 200
161-+
162-+ conn.close()
@@ -2,24 +2,14 @@
2%bcond_without tests2%bcond_without tests
3 3 
4Name: python-%{srcname}4Name: python-%{srcname}
5-Version: 2.3.05+Version: 2.7.0
6-Release: 96+Release: 1
7Summary: Sanity-friendly HTTP client for Python7Summary: Sanity-friendly HTTP client for Python
8License: MIT8License: MIT
9URL: https://github.com/urllib3/urllib39URL: https://github.com/urllib3/urllib3
10Source0: %{url}/archive/%{version}/%{srcname}-%{version}.tar.gz10Source0: %{url}/archive/%{version}/%{srcname}-%{version}.tar.gz
11Source1: ssl_match_hostname_py3.py11Source1: ssl_match_hostname_py3.py
12 12 
13-Patch001: backport-CVE-2025-50182-make-retries-and-redirect-affect-in-nodejs.patch
14-Patch002: backport-CVE-2025-50181-fix-suspend-redirect-ineffective.patch
15-Patch003: backport-CVE-2025-66418.patch
16-Patch004: backport-CVE-2025-66471-1.patch
17-Patch005: backport-CVE-2025-66471-2.patch
18-Patch006: backport-CVE-2025-66471-3.patch
19-Patch007: backport-CVE-2026-21441.patch
20-Patch008: backport-CVE-2026-44431.patch
21-Patch009: backport-CVE-2026-9375.patch
22- 
23BuildArch: noarch13BuildArch: noarch
24 14 
25%description 15%description
@@ -40,6 +30,7 @@ BuildRequires: python3-pysocks
40BuildRequires: python3-h230BuildRequires: python3-h2
41BuildRequires: python3-zstandard31BuildRequires: python3-zstandard
42 32 
33+ 
43%if %{with tests}34%if %{with tests}
44BuildRequires: python3-pytest35BuildRequires: python3-pytest
45BuildRequires: python3-pytest-timeout36BuildRequires: python3-pytest-timeout
@@ -58,7 +49,7 @@ BuildRequires: python3-httpx
58 49 
59Requires: ca-certificates python3-idna python3-pysocks50Requires: ca-certificates python3-idna python3-pysocks
60 51 
61-%python_extras_subpkg -n python3-urllib3 -i %{python3_sitelib}/urllib3-*.dist-info brotli,zstd,socks,h252+%python_extras_subpkg -n python3-urllib3 -i %{python3_sitelib}/urllib3-*.dist-info brotli,socks,h2
62 53 
63%description -n python3-urllib354%description -n python3-urllib3
64Python3 HTTP module with connection pooling and file POST abilities.55Python3 HTTP module with connection pooling and file POST abilities.
@@ -100,6 +91,13 @@ PYTHONPATH=%{buildroot}%{python3_sitelib}:%{python3_sitelib} %{__python3} -m pyt
100%{python3_sitelib}/urllib3-*.dist-info91%{python3_sitelib}/urllib3-*.dist-info
101 92 
102%changelog93%changelog
94+* Mon Jul 06 2026 Yu Peng <yupeng@kylinos.cn> - 2.7.0-1
95+- Upgrade to 2.7.0
96+ * Decompression-bomb safeguards of the streaming API were bypassed
97+ * HTTP pools created using ProxyManager.connection_from_url did not strip sensitive headers specified in Retry.remove_headers_on_redirect when redirecting to a different host.
98+ * Used FutureWarning instead of DeprecationWarning for better visibility of existing deprecation notices. Rescheduled the removal of deprecated features to version 3.0.
99+ * Fixed a bug where HTTPResponse.read(amt=None) was ignoring decompressed data buffered from previous partial reads.
100+ 
103* Sun Jun 21 2026 andy-lau <liuyang01@kylinos.cn> - 2.3.0-9101* Sun Jun 21 2026 andy-lau <liuyang01@kylinos.cn> - 2.3.0-9
104- Type:CVE102- Type:CVE
105- CVE:CVE-2026-9375103- CVE:CVE-2026-9375
Binary files do not support preview
Binary files do not support preview