已开启
Upgrade to 2.7.0 #207
roygiteee创建于 7月6日
Upgrade to 2.7.0 #207
已开启
共 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - | ||
| 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - | ||
| 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - | ||
| 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 | - 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 | - 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 | - | ||
| 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 | - 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 | - | ||
| 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 | - 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 | - 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 | - 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 | - 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 | - 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 tests | 2 | %bcond_without tests |
| 3 | 3 | ||
| 4 | Name: python-%{srcname} | 4 | Name: python-%{srcname} |
| 5 | -Version: 2.3.0 | 5 | +Version: 2.7.0 |
| 6 | -Release: 9 | 6 | +Release: 1 |
| 7 | Summary: Sanity-friendly HTTP client for Python | 7 | Summary: Sanity-friendly HTTP client for Python |
| 8 | License: MIT | 8 | License: MIT |
| 9 | URL: https://github.com/urllib3/urllib3 | 9 | URL: https://github.com/urllib3/urllib3 |
| 10 | Source0: %{url}/archive/%{version}/%{srcname}-%{version}.tar.gz | 10 | Source0: %{url}/archive/%{version}/%{srcname}-%{version}.tar.gz |
| 11 | Source1: ssl_match_hostname_py3.py | 11 | Source1: 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 | - | ||
| 23 | BuildArch: noarch | 13 | BuildArch: noarch |
| 24 | 14 | ||
| 25 | %description | 15 | %description |
| @@ -40,6 +30,7 @@ BuildRequires: python3-pysocks | |||
| 40 | BuildRequires: python3-h2 | 30 | BuildRequires: python3-h2 |
| 41 | BuildRequires: python3-zstandard | 31 | BuildRequires: python3-zstandard |
| 42 | 32 | ||
| 33 | + | ||
| 43 | %if %{with tests} | 34 | %if %{with tests} |
| 44 | BuildRequires: python3-pytest | 35 | BuildRequires: python3-pytest |
| 45 | BuildRequires: python3-pytest-timeout | 36 | BuildRequires: python3-pytest-timeout |
| @@ -58,7 +49,7 @@ BuildRequires: python3-httpx | |||
| 58 | 49 | ||
| 59 | Requires: ca-certificates python3-idna python3-pysocks | 50 | Requires: ca-certificates python3-idna python3-pysocks |
| 60 | 51 | ||
| 61 | -%python_extras_subpkg -n python3-urllib3 -i %{python3_sitelib}/urllib3-*.dist-info brotli,zstd,socks,h2 | 52 | +%python_extras_subpkg -n python3-urllib3 -i %{python3_sitelib}/urllib3-*.dist-info brotli,socks,h2 |
| 62 | 53 | ||
| 63 | %description -n python3-urllib3 | 54 | %description -n python3-urllib3 |
| 64 | Python3 HTTP module with connection pooling and file POST abilities. | 55 | Python3 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-info | 91 | %{python3_sitelib}/urllib3-*.dist-info |
| 101 | 92 | ||
| 102 | %changelog | 93 | %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-9 | 101 | * Sun Jun 21 2026 andy-lau <liuyang01@kylinos.cn> - 2.3.0-9 |
| 104 | - Type:CVE | 102 | - Type:CVE |
| 105 | - CVE:CVE-2026-9375 | 103 | - CVE:CVE-2026-9375 |
Binary files do not support preview