1717from typing import Callable
1818from urllib .parse import urlparse
1919
20+ from .._download_security import is_https_or_localhost_http , is_loopback_url
2021from . import get_provider
2122from .config import AuthConfigEntry , _default_config_path , find_entries_for_url , load_auth_config
2223
@@ -60,8 +61,27 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
6061RedirectValidator = Callable [[str , str ], None ]
6162
6263
64+ def _validate_strict_redirect (old_url : str , new_url : str ) -> None :
65+ target_is_allowed = is_https_or_localhost_http (new_url )
66+ remote_to_http_loopback = (
67+ urlparse (new_url ).scheme == "http"
68+ and not is_loopback_url (old_url )
69+ )
70+ if not target_is_allowed or remote_to_http_loopback :
71+ raise urllib .error .URLError (
72+ f"unsafe redirect to { new_url } : target must use HTTPS with a hostname, "
73+ "or stay within localhost over HTTP (127.0.0.1, ::1)"
74+ )
75+
76+
6377class _StripAuthOnRedirect (urllib .request .HTTPRedirectHandler ):
64- """Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
78+ """Redirect handler that guards every redirect it is installed for.
79+
80+ 1. Run any caller-provided redirect validator.
81+ 2. Reject redirects that are not HTTPS with a hostname. HTTP loopback is
82+ allowed only when the previous hop is also loopback.
83+ 3. Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades.
84+ """
6585
6686 def __init__ (
6787 self ,
@@ -82,6 +102,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl):
82102
83103 if self ._redirect_validator is not None :
84104 self ._redirect_validator (req .full_url , newurl )
105+ _validate_strict_redirect (req .full_url , newurl )
85106
86107 original_auth = (
87108 req .get_header ("Authorization" )
@@ -155,6 +176,10 @@ def open_url(
155176 *extra_headers* (e.g. ``Accept``) are merged into every attempt.
156177 *redirect_validator*, when provided, is called with ``(old_url, new_url)``
157178 before following each redirect and may raise to reject the redirect.
179+
180+ Redirect scheme safety: every attempt goes through
181+ ``_StripAuthOnRedirect``, which rejects redirects to non-HTTPS URLs except
182+ HTTP between localhost / 127.0.0.1 / ::1 URLs.
158183 """
159184 entries = find_entries_for_url (url , _load_config ())
160185
@@ -188,7 +213,7 @@ def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
188213
189214 # No entry worked (or none matched) — unauthenticated fallback
190215 req = _make_req ({})
191- if redirect_validator is not None :
192- opener = urllib . request . build_opener ( _StripAuthOnRedirect ((), redirect_validator ))
193- return opener . open ( req , timeout = timeout )
194- return urllib . request . urlopen (req , timeout = timeout ) # noqa: S310
216+ # No auth is attached on this path, so the handler's host list is empty :
217+ # here it runs redirect validation only, not auth stripping.
218+ opener = urllib . request . build_opener ( _StripAuthOnRedirect ((), redirect_validator ) )
219+ return opener . open (req , timeout = timeout )
0 commit comments