Skip to content

Commit 3a7a875

Browse files
authored
fix: harden bounded reads and redirect validation (#3671)
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
1 parent c0f4cee commit 3a7a875

10 files changed

Lines changed: 557 additions & 143 deletions

File tree

src/specify_cli/_download_security.py

Lines changed: 150 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
from __future__ import annotations
44

5+
import io
6+
import socket
7+
from ipaddress import IPv4Address, IPv6Address, ip_address
58
from typing import NoReturn, TypeVar
6-
from urllib.parse import urlparse
9+
from urllib.parse import ParseResult, urlparse
710

811

912
ErrorT = TypeVar("ErrorT", bound=Exception)
1013

1114
MAX_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
@@ -20,35 +23,156 @@
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.
2225
MAX_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

26137
def 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

31148
def 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

54178
def _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()

src/specify_cli/authentication/azure_devops.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ class _TokenResponseTooLarge(Exception):
2222
"""Raised when an Azure AD token response exceeds the bounded read limit."""
2323

2424

25+
def _extract_token(payload: object, key: str) -> str | None:
26+
"""Return a normalized token from a JSON object, or None for other shapes."""
27+
if not isinstance(payload, dict):
28+
return None
29+
token = payload.get(key)
30+
if not isinstance(token, str):
31+
return None
32+
return token.strip() or None
33+
34+
2535
class AzureDevOpsAuth(AuthProvider):
2636
"""Azure DevOps authentication provider.
2737
@@ -79,8 +89,7 @@ def _acquire_via_az_cli() -> str | None:
7989
if result.returncode != 0:
8090
return None
8191
payload = _json.loads(result.stdout)
82-
token = payload.get("accessToken", "").strip()
83-
return token or None
92+
return _extract_token(payload, "accessToken")
8493
except (
8594
OSError,
8695
subprocess.TimeoutExpired,
@@ -146,8 +155,7 @@ def reject_token_redirect(_old_url: str, new_url: str) -> None:
146155
label="Azure DevOps token response",
147156
).decode("utf-8")
148157
)
149-
token = payload.get("access_token", "").strip()
150-
return token or None
158+
return _extract_token(payload, "access_token")
151159
except (
152160
urllib.error.URLError,
153161
OSError,

src/specify_cli/authentication/http.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from typing import Callable
1818
from urllib.parse import urlparse
1919

20-
from .._download_security import is_https_or_localhost_http, is_loopback_url
20+
from .._download_security import is_safe_download_redirect
2121
from . import get_provider
2222
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
2323

@@ -62,15 +62,11 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
6262

6363

6464
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:
65+
if not is_safe_download_redirect(old_url, new_url):
7166
raise urllib.error.URLError(
7267
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)"
68+
"must not enter a local target from a remote host, and may use HTTP only "
69+
"within loopback (for example localhost, 127.0.0.1, ::1)"
7470
)
7571

7672

@@ -95,6 +91,8 @@ def __init__(
9591
def redirect_request(self, req, fp, code, msg, headers, newurl):
9692
try:
9793
new_parsed = urlparse(newurl)
94+
# Force urllib's syntax and range validation before following.
95+
new_parsed.port
9896
except ValueError as exc:
9997
# Malformed redirect target (e.g. unterminated IPv6 bracket).
10098
# Surface as URLError so callers' download error handling applies.
@@ -177,9 +175,11 @@ def open_url(
177175
*redirect_validator*, when provided, is called with ``(old_url, new_url)``
178176
before following each redirect and may raise to reject the redirect.
179177
178+
Every attempt uses an isolated opener so a process-wide opener installed
179+
with ``urllib.request.install_opener`` cannot replace the redirect guard.
180180
Redirect scheme safety: every attempt goes through
181181
``_StripAuthOnRedirect``, which rejects redirects to non-HTTPS URLs except
182-
HTTP between localhost / 127.0.0.1 / ::1 URLs.
182+
HTTP between loopback URLs, and rejects remote-to-local redirects.
183183
"""
184184
entries = find_entries_for_url(url, _load_config())
185185

src/specify_cli/presets/_commands.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
from rich.markup import escape as _escape_markup
1717

1818
from .._console import console
19+
from .._download_security import (
20+
is_https_or_localhost_http,
21+
is_safe_download_redirect,
22+
)
1923

2024
preset_app = typer.Typer(
2125
name="preset",
@@ -102,38 +106,25 @@ def preset_add(
102106

103107
elif from_url:
104108
# Validate URL scheme before downloading
105-
from ipaddress import ip_address
106109
from urllib.parse import urlparse as _urlparse
107110

108111
try:
109112
_parsed = _urlparse(from_url)
113+
_parsed.port
110114
except ValueError:
111115
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
112116
raise typer.Exit(1)
113117

114-
def _is_allowed_download_url(parsed_url):
115-
host = parsed_url.hostname
116-
if not host:
117-
return False
118-
is_loopback = host == "localhost"
119-
if not is_loopback:
120-
try:
121-
is_loopback = ip_address(host).is_loopback
122-
except ValueError:
123-
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
124-
pass
125-
return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback)
126-
127118
def _validate_download_redirect(old_url, new_url):
128-
if not _is_allowed_download_url(_urlparse(new_url)):
119+
if not is_safe_download_redirect(old_url, new_url):
129120
import urllib.error
130121

131122
raise urllib.error.URLError(
132-
"redirect target must use HTTPS with a hostname, "
133-
"or HTTP for localhost/loopback"
123+
"redirect target must use HTTPS without entering a local "
124+
"target, or stay within loopback over HTTP"
134125
)
135126

136-
if not _is_allowed_download_url(_parsed):
127+
if not is_https_or_localhost_http(from_url):
137128
console.print(
138129
"[red]Error:[/red] URL must use HTTPS with a hostname, "
139130
"or HTTP for localhost/loopback."
@@ -167,7 +158,7 @@ def _validate_download_redirect(old_url, new_url):
167158
redirect_validator=_validate_download_redirect,
168159
) as response:
169160
final_url = response.geturl() if hasattr(response, "geturl") else from_url
170-
if not _is_allowed_download_url(_urlparse(final_url)):
161+
if not is_https_or_localhost_http(final_url):
171162
console.print(
172163
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
173164
f"{final_url}. Redirect targets must use HTTPS with a hostname, "

0 commit comments

Comments
 (0)