22
33from __future__ import annotations
44
5+ import io
6+ import socket
7+ from ipaddress import IPv4Address , IPv6Address , ip_address
58from typing import NoReturn , TypeVar
6- from urllib .parse import urlparse
9+ from urllib .parse import ParseResult , urlparse
710
811
912ErrorT = TypeVar ("ErrorT" , bound = Exception )
1013
1114MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
12- READ_CHUNK_SIZE = 1024 * 1024
15+ READ_CHUNK_SIZE = 64 * 1024
1316
1417# Tighter ceiling for responses that are read fully into memory and parsed as
1518# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
2023# METADATA covers fixed-shape single-object responses (an OAuth token, one
2124# release's metadata): a few KiB in practice, 1 MiB is already generous.
2225MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
23- _LOOPBACK_HOSTS = frozenset (("localhost" , "127.0.0.1" , "::1" ))
26+
27+
28+ def _ip_address_without_scope (
29+ hostname : str ,
30+ ) -> IPv4Address | IPv6Address | None :
31+ """Parse a canonical IP literal, validating an optional IPv6 zone ID."""
32+ if "%" in hostname :
33+ # Accept only the RFC 6874 ``%25<zone>`` spelling. Other escapes can
34+ # alter the IPv6 address when urllib unquotes the authority.
35+ address_text , separator , zone = hostname .partition ("%25" )
36+ if (
37+ not separator
38+ or ":" not in address_text
39+ or "%" in address_text
40+ or "%" in zone
41+ ):
42+ return None
43+ if not zone or any (
44+ not (character .isascii () and (character .isalnum () or character in "._~-" ))
45+ for character in zone
46+ ):
47+ return None
48+ else :
49+ address_text = hostname
50+ try :
51+ address = ip_address (address_text )
52+ except ValueError :
53+ return None
54+ if "%" in hostname and not isinstance (address , IPv6Address ):
55+ return None
56+ return address
57+
58+
59+ def _is_ip_loopback (address : IPv4Address | IPv6Address | None ) -> bool :
60+ if address is None :
61+ return False
62+ mapped = getattr (address , "ipv4_mapped" , None )
63+ return address .is_loopback or bool (mapped and mapped .is_loopback )
64+
65+
66+ def _is_ip_local_redirect_target (
67+ address : IPv4Address | IPv6Address | None ,
68+ ) -> bool :
69+ """Treat loopback and unspecified listener aliases as local targets."""
70+ if address is None :
71+ return False
72+ mapped = getattr (address , "ipv4_mapped" , None )
73+ return _is_ip_loopback (address ) or address .is_unspecified or bool (
74+ mapped and mapped .is_unspecified
75+ )
76+
77+
78+ def _parse_url (url : str ) -> ParseResult | None :
79+ """Parse *url*, rejecting missing hosts and malformed ports."""
80+ try :
81+ parsed = urlparse (url )
82+ hostname = parsed .hostname
83+ # Accessing ``port`` performs urllib's range and syntax validation.
84+ parsed .port
85+ except (TypeError , ValueError ):
86+ return None
87+ if not hostname :
88+ return None
89+
90+ if "%" in hostname :
91+ # urllib unquotes reg-name/IPv4 authorities before connecting. Reject
92+ # them so encoded dots, characters, ports, or brackets cannot make the
93+ # validated hostname differ from the effective target. The only safe
94+ # percent form retained is a validated bracketed IPv6 zone ID.
95+ if _ip_address_without_scope (hostname ) is None :
96+ return None
97+ elif ":" not in hostname :
98+ try :
99+ hostname .encode ("idna" )
100+ except UnicodeError :
101+ return None
102+ return parsed
103+
104+
105+ def _is_definite_loopback_host (hostname : str ) -> bool :
106+ """Recognize only unambiguous hosts that may safely authorize HTTP."""
107+ if not hostname .isascii ():
108+ return False
109+ if hostname == "localhost" :
110+ return True
111+ return _is_ip_loopback (_ip_address_without_scope (hostname ))
112+
113+
114+ def _is_potential_local_target_host (hostname : str ) -> bool :
115+ """Conservatively classify aliases that could reach a local listener."""
116+ if ":" in hostname :
117+ return _is_ip_local_redirect_target (_ip_address_without_scope (hostname ))
118+ try :
119+ host = hostname .encode ("idna" ).decode ("ascii" ).lower ().removesuffix ("." )
120+ except UnicodeError :
121+ return False
122+ if host == "localhost" or host .endswith (".localhost" ):
123+ return True
124+
125+ address = _ip_address_without_scope (host )
126+ if address is None :
127+ # Historical IPv4 spellings are resolver-dependent. They are never
128+ # trusted to authorize HTTP, but treating them as potentially local
129+ # prevents them from bypassing a remote-to-loopback redirect check.
130+ try :
131+ address = ip_address (socket .inet_aton (host ))
132+ except OSError :
133+ return False
134+ return _is_ip_local_redirect_target (address )
24135
25136
26137def is_loopback_url (url : str ) -> bool :
27- """Return whether *url* targets an explicitly allowed loopback host."""
28- return urlparse (url ).hostname in _LOOPBACK_HOSTS
138+ """Return whether *url* has an unambiguous loopback host."""
139+ parsed = _parse_url (url )
140+ return parsed is not None and _is_definite_loopback_host (parsed .hostname )
141+
142+
143+ def _is_potential_local_target_url (url : str ) -> bool :
144+ parsed = _parse_url (url )
145+ return parsed is not None and _is_potential_local_target_host (parsed .hostname )
29146
30147
31148def is_https_or_localhost_http (url : str ) -> bool :
32149 """Return True if *url* is HTTPS, or HTTP limited to loopback hosts.
33150
34151 Shared scheme-safety predicate used by the auth HTTP redirect handler and
35- by the direct URL validations in the CLI download flows, so the rule (and
36- any future tightening of it) lives in one place.
152+ direct URL validations in CLI download flows.
37153
38154 A hostname is always required: a URL without one (e.g. ``https:///x``)
39155 has no real target and is rejected regardless of scheme.
40156
41- The loopback allowance is a deliberate *exact-string* match on
42- ``localhost`` / ``127.0.0.1`` / ``::1``, not an IP-range check: other
43- loopback addresses (e.g. ``127.0.0.2``) are intentionally not covered.
44- ``urlparse`` already lower-cases the hostname, so the comparison is
45- case-insensitive .
157+ The HTTP exception is deliberately limited to unambiguous ``localhost``
158+ and canonical IPv4/IPv6 loopback literals. Ambiguous numeric, Unicode, and
159+ unspecified-address aliases are classified defensively for redirects but
160+ never authorize HTTP. No DNS lookup is performed; DNS and hosts-file
161+ aliases require connection-level rebinding protection outside this helper .
46162 """
47- parsed = urlparse (url )
48- if not parsed . hostname :
163+ parsed = _parse_url (url )
164+ if parsed is None :
49165 return False
50- is_localhost = parsed .hostname in _LOOPBACK_HOSTS
51- return parsed .scheme == "https" or (parsed .scheme == "http" and is_localhost )
166+ return parsed .scheme == "https" or (
167+ parsed .scheme == "http" and _is_definite_loopback_host (parsed .hostname )
168+ )
169+
170+
171+ def is_safe_download_redirect (old_url : str , new_url : str ) -> bool :
172+ """Return whether a redirect preserves the shared download URL policy."""
173+ if not is_https_or_localhost_http (new_url ):
174+ return False
175+ return not _is_potential_local_target_url (new_url ) or is_loopback_url (old_url )
52176
53177
54178def _raise (error_type : type [ErrorT ], message : str ) -> NoReturn :
@@ -75,15 +199,20 @@ def read_response_limited(
75199 explicit value so the intended bound is pinned at the call site rather than
76200 tracking changes to the shared default.
77201 """
78- chunks : list [bytes ] = []
202+ if isinstance (max_bytes , bool ) or not isinstance (max_bytes , int ):
203+ raise TypeError ("max_bytes must be an integer" )
204+ if max_bytes < 0 :
205+ raise ValueError ("max_bytes must be non-negative" )
206+
207+ output = io .BytesIO ()
79208 total = 0
80209 limit = max_bytes + 1
81210 while total < limit :
82211 chunk = response .read (min (READ_CHUNK_SIZE , limit - total ))
83212 if not chunk :
84213 break
85- chunks .append (chunk )
86214 total += len (chunk )
87- if total > max_bytes :
88- _raise (error_type , f"{ label } exceeds maximum size of { max_bytes } bytes" )
89- return b"" .join (chunks )
215+ if total > max_bytes :
216+ _raise (error_type , f"{ label } exceeds maximum size of { max_bytes } bytes" )
217+ output .write (chunk )
218+ return output .getvalue ()
0 commit comments