From 8863c526851f76884758a6ed3ee5319f870bb4e4 Mon Sep 17 00:00:00 2001 From: abdul rawoof Date: Mon, 13 Jul 2026 13:42:42 +0530 Subject: [PATCH 1/3] reject redirects to non-http(s) schemes --- news/14171.bugfix.rst | 1 + src/pip/_internal/network/session.py | 26 +++++++++++++++- tests/unit/test_network_session.py | 44 ++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 news/14171.bugfix.rst diff --git a/news/14171.bugfix.rst b/news/14171.bugfix.rst new file mode 100644 index 0000000000..b00b08185d --- /dev/null +++ b/news/14171.bugfix.rst @@ -0,0 +1 @@ +Refuse to follow an HTTP redirect that switches to a non-``http(s)`` scheme, so a remote server can no longer redirect a request into a ``file://`` URL and make pip read a local path. diff --git a/src/pip/_internal/network/session.py b/src/pip/_internal/network/session.py index 3ce4e5741a..70002b2a6d 100644 --- a/src/pip/_internal/network/session.py +++ b/src/pip/_internal/network/session.py @@ -42,7 +42,11 @@ # Import ssl from compat so the initial import occurs in only one place. from pip._internal.utils.compat import has_tls from pip._internal.utils.glibc import libc_ver -from pip._internal.utils.misc import build_url_from_netloc, parse_netloc +from pip._internal.utils.misc import ( + build_url_from_netloc, + parse_netloc, + redact_auth_from_url, +) from pip._internal.utils.urls import url_to_path if TYPE_CHECKING: @@ -526,6 +530,26 @@ def is_secure_origin(self, location: Link) -> bool: return False + def get_redirect_target(self, resp: Response) -> str | None: + target = super().get_redirect_target(resp) + if target is None: + return None + # A file:// URL is served by LocalFSAdapter, so following a redirect + # into it would let a remote server make pip read a local path (or, via + # a UNC target on Windows, reach an SMB share). Only follow redirects + # that stay on http(s); a relative target carries no scheme here and + # inherits the current one. + scheme = urllib.parse.urlparse(target).scheme + if scheme and scheme.lower() not in ("http", "https"): + logger.warning( + "Not following redirect from %s to %s: a redirect to a non-http(s)" + " location is not allowed.", + redact_auth_from_url(resp.url), + redact_auth_from_url(target), + ) + return None + return target + def request(self, method: str, url: str, *args: Any, **kwargs: Any) -> Response: # type: ignore[override] # Allow setting a default timeout on a session kwargs.setdefault("timeout", self.timeout) diff --git a/tests/unit/test_network_session.py b/tests/unit/test_network_session.py index 67163757db..82d1aab107 100644 --- a/tests/unit/test_network_session.py +++ b/tests/unit/test_network_session.py @@ -22,6 +22,7 @@ PipSession, user_agent, ) +from pip._internal.utils.urls import path_to_url def get_user_agent() -> str: @@ -384,3 +385,46 @@ def test_unset_proxy_is_distinct_from_empty( assert options.proxy is None assert session.trust_env is True assert self._resolved_proxy(session, "http://example.com") is not None + + +def _make_redirect_response(location: str) -> requests.Response: + resp = requests.Response() + resp.status_code = 302 + resp.headers["Location"] = location + resp.url = "https://example.com/simple/foo/" + request = requests.PreparedRequest() + request.prepare(method="GET", url=resp.url, headers={}) + resp.request = request + return resp + + +def test_get_redirect_target_refuses_file_scheme( + tmpdir: Path, caplog: pytest.LogCaptureFixture +) -> None: + # A remote server must not be able to redirect pip into the file:// adapter + # and have it read a local file. + secret = tmpdir.joinpath("secret.txt") + secret.write_text("s3cr3t", encoding="utf-8") + resp = _make_redirect_response(path_to_url(str(secret))) + + session = PipSession() + with caplog.at_level(logging.WARNING): + assert session.get_redirect_target(resp) is None + assert "non-http(s) location is not allowed" in caplog.text + + # Following the redirect the way requests does must not read the file. + followed = list(session.resolve_redirects(resp, resp.request)) + assert followed == [] + + +@pytest.mark.parametrize( + "location", + [ + "https://other.example.com/elsewhere/", + "http://other.example.com/elsewhere/", + "/relative/path/", + ], +) +def test_get_redirect_target_allows_http_schemes(location: str) -> None: + resp = _make_redirect_response(location) + assert PipSession().get_redirect_target(resp) == location From 7d664f17edda67ec7d228560dbe49d536e2e987a Mon Sep 17 00:00:00 2001 From: Richard Si Date: Wed, 5 Aug 2026 21:26:31 -0400 Subject: [PATCH 2/3] Simplify comment and news entry --- news/14171.bugfix.rst | 2 +- src/pip/_internal/network/session.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/news/14171.bugfix.rst b/news/14171.bugfix.rst index b00b08185d..66ee8c2f6c 100644 --- a/news/14171.bugfix.rst +++ b/news/14171.bugfix.rst @@ -1 +1 @@ -Refuse to follow an HTTP redirect that switches to a non-``http(s)`` scheme, so a remote server can no longer redirect a request into a ``file://`` URL and make pip read a local path. +Refuse to follow an HTTP redirect to a ``file:`` URL. diff --git a/src/pip/_internal/network/session.py b/src/pip/_internal/network/session.py index 70002b2a6d..7c8d352075 100644 --- a/src/pip/_internal/network/session.py +++ b/src/pip/_internal/network/session.py @@ -534,11 +534,8 @@ def get_redirect_target(self, resp: Response) -> str | None: target = super().get_redirect_target(resp) if target is None: return None - # A file:// URL is served by LocalFSAdapter, so following a redirect - # into it would let a remote server make pip read a local path (or, via - # a UNC target on Windows, reach an SMB share). Only follow redirects - # that stay on http(s); a relative target carries no scheme here and - # inherits the current one. + # Redirecting to a file:// URL doesn't make much sense and + # shouldn't be allowed. scheme = urllib.parse.urlparse(target).scheme if scheme and scheme.lower() not in ("http", "https"): logger.warning( From 26cc92ac296db45d235c4652323ea6ea809b57f4 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Wed, 5 Aug 2026 21:32:25 -0400 Subject: [PATCH 3/3] Move the tests to a class --- tests/unit/test_network_session.py | 76 +++++++++++++++--------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/tests/unit/test_network_session.py b/tests/unit/test_network_session.py index 558e25588a..60e0891b00 100644 --- a/tests/unit/test_network_session.py +++ b/tests/unit/test_network_session.py @@ -462,47 +462,47 @@ def test_unset_proxy_is_distinct_from_empty( assert self._resolved_proxy(session, "http://example.com") is not None -def _make_redirect_response(location: str) -> requests.Response: - resp = requests.Response() - resp.status_code = 302 - resp.headers["Location"] = location - resp.url = "https://example.com/simple/foo/" - request = requests.PreparedRequest() - request.prepare(method="GET", url=resp.url, headers={}) - resp.request = request - return resp - - -def test_get_redirect_target_refuses_file_scheme( - tmpdir: Path, caplog: pytest.LogCaptureFixture -) -> None: - # A remote server must not be able to redirect pip into the file:// adapter - # and have it read a local file. - secret = tmpdir.joinpath("secret.txt") - secret.write_text("s3cr3t", encoding="utf-8") - resp = _make_redirect_response(path_to_url(str(secret))) - - session = PipSession() - with caplog.at_level(logging.WARNING): - assert session.get_redirect_target(resp) is None - assert "non-http(s) location is not allowed" in caplog.text +class TestRedirectScheme: + @staticmethod + def _make_redirect_response(location: str) -> requests.Response: + resp = requests.Response() + resp.status_code = 302 + resp.headers["Location"] = location + resp.url = "https://example.com/simple/foo/" + request = requests.PreparedRequest() + request.prepare(method="GET", url=resp.url, headers={}) + resp.request = request + return resp + + def test_get_redirect_target_refuses_file_scheme( + self, tmpdir: Path, caplog: pytest.LogCaptureFixture + ) -> None: + # A remote server must not be able to redirect pip into the file:// adapter + # and have it read a local file. + secret = tmpdir.joinpath("secret.txt") + secret.write_text("s3cr3t", encoding="utf-8") + resp = self._make_redirect_response(path_to_url(str(secret))) - # Following the redirect the way requests does must not read the file. - followed = list(session.resolve_redirects(resp, resp.request)) - assert followed == [] + session = PipSession() + with caplog.at_level(logging.WARNING): + assert session.get_redirect_target(resp) is None + assert "non-http(s) location is not allowed" in caplog.text + # Following the redirect the way requests does must not read the file. + followed = list(session.resolve_redirects(resp, resp.request)) + assert followed == [] -@pytest.mark.parametrize( - "location", - [ - "https://other.example.com/elsewhere/", - "http://other.example.com/elsewhere/", - "/relative/path/", - ], -) -def test_get_redirect_target_allows_http_schemes(location: str) -> None: - resp = _make_redirect_response(location) - assert PipSession().get_redirect_target(resp) == location + @pytest.mark.parametrize( + "location", + [ + "https://other.example.com/elsewhere/", + "http://other.example.com/elsewhere/", + "/relative/path/", + ], + ) + def test_get_redirect_target_allows_http_schemes(self, location: str) -> None: + resp = self._make_redirect_response(location) + assert PipSession().get_redirect_target(resp) == location class TestSSLContextAdapterMixinProxy: