From 3f56208010075811b4790e432f0d1eb60d057fbf Mon Sep 17 00:00:00 2001 From: devl00p Date: Tue, 14 Jul 2026 11:14:23 +0200 Subject: [PATCH] Preserve response when a redirect has an invalid Location and redirects are not followed When follow_redirects=False and a 3XX response carries a malformed Location header (e.g. a data: URI), _send_handling_redirects builds the redirect request before checking follow_redirects, so _redirect_url raises RemoteProtocolError and the response, along with its headers, is discarded. A caller that only wants to inspect the redirect without following it never sees the response. Defer building the redirect request to the branch that actually follows redirects. When not following, building next_request is best-effort: a malformed Location leaves next_request=None and the response is returned intact. Adds sync and async regression tests. Refs: https://github.com/wapiti-scanner/wapiti/issues/690 Refs: https://github.com/encode/httpx/pull/3706 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/httpx2/httpx2/_client.py | 26 ++++++++++++++++++-------- tests/httpx2/client/test_redirects.py | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index ae73434e..1c6276f4 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -1054,13 +1054,18 @@ def _send_handling_redirects( if not response.has_redirect_location: return response - request = self._build_redirect_request(request, response) - history = history + [response] - if follow_redirects: + request = self._build_redirect_request(request, response) + history = history + [response] response.read() else: - response.next_request = request + # When not following redirects, building the next request is + # best-effort: a malformed 'Location' must not discard the + # response and its headers. Leave next_request as None. + try: + response.next_request = self._build_redirect_request(request, response) + except (InvalidURL, RemoteProtocolError): + pass return response except BaseException as exc: @@ -1892,13 +1897,18 @@ async def _send_handling_redirects( if not response.has_redirect_location: return response - request = self._build_redirect_request(request, response) - history = history + [response] - if follow_redirects: + request = self._build_redirect_request(request, response) + history = history + [response] await response.aread() else: - response.next_request = request + # When not following redirects, building the next request is + # best-effort: a malformed 'Location' must not discard the + # response and its headers. Leave next_request as None. + try: + response.next_request = self._build_redirect_request(request, response) + except (InvalidURL, RemoteProtocolError): + pass return response except BaseException as exc: diff --git a/tests/httpx2/client/test_redirects.py b/tests/httpx2/client/test_redirects.py index 0dee968c..e7f434c5 100644 --- a/tests/httpx2/client/test_redirects.py +++ b/tests/httpx2/client/test_redirects.py @@ -208,6 +208,17 @@ def test_invalid_redirect() -> None: client.get("http://example.org/invalid_redirect", follow_redirects=True) +def test_invalid_redirect_not_followed() -> None: + # A malformed 'Location' must not discard the response when we are not + # following redirects: the response and its headers are returned as-is, + # with next_request left as None. + client = httpx2.Client(transport=httpx2.MockTransport(redirects)) + response = client.get("http://example.org/invalid_redirect", follow_redirects=False) + assert response.status_code == httpx2.codes.SEE_OTHER + assert response.headers["location"] == "https://😇/" + assert response.next_request is None + + def test_no_scheme_redirect() -> None: client = httpx2.Client(transport=httpx2.MockTransport(redirects)) response = client.get("https://example.org/no_scheme_redirect", follow_redirects=True) @@ -458,3 +469,12 @@ async def test_async_invalid_redirect() -> None: async with httpx2.AsyncClient(transport=httpx2.MockTransport(redirects)) as client: with pytest.raises(httpx2.RemoteProtocolError): await client.get("http://example.org/invalid_redirect", follow_redirects=True) + + +@pytest.mark.anyio +async def test_async_invalid_redirect_not_followed() -> None: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(redirects)) as client: + response = await client.get("http://example.org/invalid_redirect", follow_redirects=False) + assert response.status_code == httpx2.codes.SEE_OTHER + assert response.headers["location"] == "https://😇/" + assert response.next_request is None