diff --git a/.readthedocs.yml b/.readthedocs.yml index 78bd2064f6..7c59df5b3a 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,7 +3,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3" + python: "3.11" python: install: diff --git a/CHANGES.rst b/CHANGES.rst index e551cbea40..6c37aeba10 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,8 @@ +2.0.7 (2023-10-17) +================== + +* Made body stripped from HTTP requests changing the request method to GET after HTTP 303 "See Other" redirect responses. + 2.0.6 (2023-10-02) ================== @@ -167,6 +172,16 @@ Fixed * Fixed a socket leak if ``HTTPConnection.connect()`` fails (`#2571 `__). * Fixed ``urllib3.contrib.pyopenssl.WrappedSocket`` and ``urllib3.contrib.securetransport.WrappedSocket`` close methods (`#2970 `__) +1.26.18 (2023-10-17) +==================== + +* Made body stripped from HTTP requests changing the request method to GET after HTTP 303 "See Other" redirect responses. + +1.26.17 (2023-10-02) +==================== + +* Added the ``Cookie`` header to the list of headers to strip from requests when redirecting to a different host. As before, different headers can be set via ``Retry.remove_headers_on_redirect``. (`#3139 `_) + 1.26.16 (2023-05-23) ==================== diff --git a/dev-requirements.txt b/dev-requirements.txt index 70f5097338..8b3632c37f 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -10,4 +10,4 @@ cryptography==39.0.2;implementation_name=="pypy" and implementation_version<"7.3 cryptography==41.0.4;implementation_name!="pypy" or implementation_version>="7.3.10" backports.zoneinfo==0.2.1;python_version<"3.9" towncrier==23.6.0 -pytest-memray==1.4.0;python_version>="3.8" and python_version<"3.12" and sys_platform!="win32" and implementation_name=="cpython" +pytest-memray==1.4.0;python_version>="3.8" and python_version<"3.13" and sys_platform!="win32" and implementation_name=="cpython" diff --git a/dummyserver/handlers.py b/dummyserver/handlers.py index 9fde80e041..86201a116f 100644 --- a/dummyserver/handlers.py +++ b/dummyserver/handlers.py @@ -281,6 +281,12 @@ def encodingrequest(self, request: httputil.HTTPServerRequest) -> Response: def headers(self, request: httputil.HTTPServerRequest) -> Response: return Response(json.dumps(dict(request.headers))) + def headers_and_params(self, request: httputil.HTTPServerRequest) -> Response: + params = request_params(request) + return Response( + json.dumps({"headers": dict(request.headers), "params": params}) + ) + def multi_headers(self, request: httputil.HTTPServerRequest) -> Response: return Response(json.dumps({"headers": list(request.headers.get_all())})) diff --git a/src/urllib3/_collections.py b/src/urllib3/_collections.py index 7f9dca7fa8..8bdfb767e6 100644 --- a/src/urllib3/_collections.py +++ b/src/urllib3/_collections.py @@ -8,7 +8,7 @@ if typing.TYPE_CHECKING: # We can only import Protocol if TYPE_CHECKING because it's a development # dependency, and is not available at runtime. - from typing_extensions import Protocol + from typing_extensions import Protocol, Self class HasGettableStringKeys(Protocol): def keys(self) -> typing.Iterator[str]: @@ -391,6 +391,24 @@ def getlist( # meets our external interface requirement of `Union[List[str], _DT]`. return vals[1:] + def _prepare_for_method_change(self) -> Self: + """ + Remove content-specific header fields before changing the request + method to GET or HEAD according to RFC 9110, Section 15.4. + """ + content_specific_headers = [ + "Content-Encoding", + "Content-Language", + "Content-Location", + "Content-Type", + "Content-Length", + "Digest", + "Last-Modified", + ] + for header in content_specific_headers: + self.discard(header) + return self + # Backwards compatibility for httplib getheaders = getlist getallmatchingheaders = getlist diff --git a/src/urllib3/_version.py b/src/urllib3/_version.py index 2d0d430896..e2b88f1d68 100644 --- a/src/urllib3/_version.py +++ b/src/urllib3/_version.py @@ -1,4 +1,4 @@ # This file is protected via CODEOWNERS from __future__ import annotations -__version__ = "2.0.6" +__version__ = "2.0.7" diff --git a/src/urllib3/connectionpool.py b/src/urllib3/connectionpool.py index 2479405bd5..c6ca390247 100644 --- a/src/urllib3/connectionpool.py +++ b/src/urllib3/connectionpool.py @@ -11,6 +11,7 @@ from types import TracebackType from ._base_connection import _TYPE_BODY +from ._collections import HTTPHeaderDict from ._request_methods import RequestMethods from .connection import ( BaseSSLError, @@ -893,7 +894,11 @@ def urlopen( # type: ignore[override] redirect_location = redirect and response.get_redirect_location() if redirect_location: if response.status == 303: + # Change the method according to RFC 9110, Section 15.4.4. method = "GET" + # And lose the body not to transfer anything sensitive. + body = None + headers = HTTPHeaderDict(headers)._prepare_for_method_change() try: retries = retries.increment(method, url, response=response, _pool=self) diff --git a/src/urllib3/poolmanager.py b/src/urllib3/poolmanager.py index 02b2f622a1..3c92a14deb 100644 --- a/src/urllib3/poolmanager.py +++ b/src/urllib3/poolmanager.py @@ -7,7 +7,7 @@ from types import TracebackType from urllib.parse import urljoin -from ._collections import RecentlyUsedContainer +from ._collections import HTTPHeaderDict, RecentlyUsedContainer from ._request_methods import RequestMethods from .connection import ProxyConfig from .connectionpool import HTTPConnectionPool, HTTPSConnectionPool, port_by_scheme @@ -449,9 +449,12 @@ def urlopen( # type: ignore[override] # Support relative URLs for redirecting. redirect_location = urljoin(url, redirect_location) - # RFC 7231, Section 6.4.4 if response.status == 303: + # Change the method according to RFC 9110, Section 15.4.4. method = "GET" + # And lose the body not to transfer anything sensitive. + kw["body"] = None + kw["headers"] = HTTPHeaderDict(kw["headers"])._prepare_for_method_change() retries = kw.get("retries") if not isinstance(retries, Retry): diff --git a/src/urllib3/util/ssl_.py b/src/urllib3/util/ssl_.py index 7762803267..e35e394030 100644 --- a/src/urllib3/util/ssl_.py +++ b/src/urllib3/util/ssl_.py @@ -411,8 +411,10 @@ def ssl_wrap_socket( tls_in_tls: bool = False, ) -> ssl.SSLSocket | SSLTransportType: """ - All arguments except for server_hostname, ssl_context, and ca_cert_dir have - the same meaning as they do when using :func:`ssl.wrap_socket`. + All arguments except for server_hostname, ssl_context, tls_in_tls, ca_cert_data and + ca_cert_dir have the same meaning as they do when using + :func:`ssl.create_default_context`, :meth:`ssl.SSLContext.load_cert_chain`, + :meth:`ssl.SSLContext.set_ciphers` and :meth:`ssl.SSLContext.wrap_socket`. :param server_hostname: When SNI is supported, the expected hostname of the certificate diff --git a/test/with_dummyserver/test_connectionpool.py b/test/with_dummyserver/test_connectionpool.py index fdfb2c9aba..ebfaf3878f 100644 --- a/test/with_dummyserver/test_connectionpool.py +++ b/test/with_dummyserver/test_connectionpool.py @@ -480,6 +480,17 @@ def test_redirect(self) -> None: assert r.status == 200 assert r.data == b"Dummy server!" + def test_303_redirect_makes_request_lose_body(self) -> None: + with HTTPConnectionPool(self.host, self.port) as pool: + response = pool.request( + "POST", + "/redirect", + fields={"target": "/headers_and_params", "status": "303 See Other"}, + ) + data = response.json() + assert data["params"] == {} + assert "Content-Type" not in HTTPHeaderDict(data["headers"]) + def test_bad_connect(self) -> None: with HTTPConnectionPool("badhost.invalid", self.port) as pool: with pytest.raises(MaxRetryError) as e: diff --git a/test/with_dummyserver/test_poolmanager.py b/test/with_dummyserver/test_poolmanager.py index da802a38b3..ab0111e45b 100644 --- a/test/with_dummyserver/test_poolmanager.py +++ b/test/with_dummyserver/test_poolmanager.py @@ -244,6 +244,20 @@ def test_redirect_without_preload_releases_connection(self) -> None: assert r._pool.num_connections == 1 assert len(http.pools) == 1 + def test_303_redirect_makes_request_lose_body(self) -> None: + with PoolManager() as http: + response = http.request( + "POST", + f"{self.base_url}/redirect", + fields={ + "target": f"{self.base_url}/headers_and_params", + "status": "303 See Other", + }, + ) + data = response.json() + assert data["params"] == {} + assert "Content-Type" not in HTTPHeaderDict(data["headers"]) + def test_unknown_scheme(self) -> None: with PoolManager() as http: unknown_scheme = "unknown"