Skip to content

Commit 5601830

Browse files
authored
harden: bound HTTP reads and enforce strict redirects (#3140)
* harden: bound HTTP reads and enforce strict redirects Add a shared _download_security module (read_response_limited, is_https_or_localhost_http, size constants) and route the GitHub release and Azure DevOps token network reads through bounded reads so an oversized response can't exhaust memory. Add a strict_redirects mode to authentication.open_url: the redirect handler now rejects any redirect whose target isn't HTTPS (or HTTP to localhost), composing with the existing per-hop redirect_validator and auth-stripping. The Azure DevOps token POST is routed through that handler so a 307/308 cannot forward the client_secret body to a non-HTTPS host. Assisted-by: Codex (model: GPT-5, autonomous) * test: align HTTP fakes with bounded reads Assisted-by: Codex (model: GPT-5, autonomous) * fix: tolerate invalid token response encoding Assisted-by: Codex (model: GPT-5, autonomous) * test: align GHES fakes with bounded reads Assisted-by: Codex (model: GPT-5, autonomous) * test: reuse shared upgrade HTTP response helper Assisted-by: Codex (model: GPT-5, autonomous) * fix: include rejected redirect target in error Assisted-by: Codex (model: GPT-5, autonomous) * fix: enforce strict redirects by default Assisted-by: Codex (model: GPT-5, autonomous) * fix: close redirect credential and SSRF gaps Assisted-by: Codex (model: GPT-5, autonomous)
1 parent 0f6ea64 commit 5601830

18 files changed

Lines changed: 592 additions & 115 deletions
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Helpers for bounded HTTP downloads."""
2+
3+
from __future__ import annotations
4+
5+
from typing import NoReturn, TypeVar
6+
from urllib.parse import urlparse
7+
8+
9+
ErrorT = TypeVar("ErrorT", bound=Exception)
10+
11+
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
12+
READ_CHUNK_SIZE = 1024 * 1024
13+
14+
# Tighter ceiling for responses that are read fully into memory and parsed as
15+
# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
16+
# downloads; JSON metadata responses are far smaller, so capping them close to
17+
# their real size shrinks the memory-DoS surface and keeps the "too large"
18+
# error reachable (rather than only triggering on tens of MiB). Pass it
19+
# explicitly at each JSON call site so the intended bound is pinned there.
20+
# METADATA covers fixed-shape single-object responses (an OAuth token, one
21+
# release's metadata): a few KiB in practice, 1 MiB is already generous.
22+
MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
23+
_LOOPBACK_HOSTS = frozenset(("localhost", "127.0.0.1", "::1"))
24+
25+
26+
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
29+
30+
31+
def is_https_or_localhost_http(url: str) -> bool:
32+
"""Return True if *url* is HTTPS, or HTTP limited to loopback hosts.
33+
34+
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.
37+
38+
A hostname is always required: a URL without one (e.g. ``https:///x``)
39+
has no real target and is rejected regardless of scheme.
40+
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.
46+
"""
47+
parsed = urlparse(url)
48+
if not parsed.hostname:
49+
return False
50+
is_localhost = parsed.hostname in _LOOPBACK_HOSTS
51+
return parsed.scheme == "https" or (parsed.scheme == "http" and is_localhost)
52+
53+
54+
def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
55+
raise error_type(message)
56+
57+
58+
def read_response_limited(
59+
response,
60+
*,
61+
max_bytes: int = MAX_DOWNLOAD_BYTES,
62+
error_type: type[ErrorT] = ValueError,
63+
label: str = "download",
64+
) -> bytes:
65+
"""Read at most *max_bytes* from a response object.
66+
67+
``response.read(n)`` is only guaranteed to return *up to* ``n`` bytes and may
68+
return fewer even when more data is pending (e.g. chunked transfer encoding),
69+
so a single ``read(max_bytes + 1)`` cannot enforce the bound on its own. Read
70+
in a loop until EOF or until one byte past the limit has been accumulated.
71+
72+
*max_bytes* is keyword-only. It defaults to the module-wide
73+
``MAX_DOWNLOAD_BYTES`` (50 MiB) ceiling for archive/payload downloads;
74+
callers with a tighter budget (e.g. small JSON responses) should pass an
75+
explicit value so the intended bound is pinned at the call site rather than
76+
tracking changes to the shared default.
77+
"""
78+
chunks: list[bytes] = []
79+
total = 0
80+
limit = max_bytes + 1
81+
while total < limit:
82+
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
83+
if not chunk:
84+
break
85+
chunks.append(chunk)
86+
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)

src/specify_cli/_github_http.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ def resolve_github_release_asset_api_url(
100100
import json
101101
import urllib.error
102102

103+
from specify_cli._download_security import read_response_limited
104+
103105
parsed = urlparse(download_url)
104106
hostname = (parsed.hostname or "").lower()
105107
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
@@ -158,10 +160,13 @@ def _is_asset_path(segments: list[str]) -> bool:
158160
if redirect_validator is not None:
159161
open_kwargs["redirect_validator"] = redirect_validator
160162
with open_url_fn(release_url, **open_kwargs) as response:
161-
raw_release_data = response.read(max_metadata_bytes + 1)
162-
if len(raw_release_data) > max_metadata_bytes:
163-
raise ValueError("GitHub release metadata exceeds size limit")
164-
release_data = json.loads(raw_release_data)
163+
release_data = json.loads(
164+
read_response_limited(
165+
response,
166+
max_bytes=max_metadata_bytes,
167+
label=f"GitHub release metadata {release_url}",
168+
)
169+
)
165170
except (
166171
urllib.error.URLError,
167172
json.JSONDecodeError,

src/specify_cli/_version.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
release tag. The ``self_app`` Typer sub-command group is co-located here so
55
all version-related logic lives in one place.
66
7-
Dependencies: stdlib + packaging + ._console only (no other internal imports
8-
at module level, keeping this layer thin and circular-import-safe).
7+
Dependencies: stdlib + packaging + ._console + ._download_security only
8+
(keeping this layer thin and circular-import-safe).
99
"""
1010
from __future__ import annotations
1111

@@ -28,6 +28,7 @@
2828
import typer
2929
from packaging.version import InvalidVersion, Version
3030

31+
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
3132
from ._console import console
3233

3334
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
@@ -119,7 +120,13 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
119120
timeout=5,
120121
extra_headers={"Accept": "application/vnd.github+json"},
121122
) as resp:
122-
payload = json.loads(resp.read().decode("utf-8"))
123+
payload = json.loads(
124+
read_response_limited(
125+
resp,
126+
max_bytes=MAX_JSON_METADATA_BYTES,
127+
label="GitHub latest release",
128+
).decode("utf-8")
129+
)
123130
tag = payload.get("tag_name")
124131
if not isinstance(tag, str) or not tag:
125132
raise ValueError("GitHub API response missing valid tag_name")

src/specify_cli/authentication/azure_devops.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import subprocess
99
from typing import TYPE_CHECKING
1010

11+
from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
1112
from .base import AuthProvider
1213

1314
if TYPE_CHECKING:
@@ -17,6 +18,10 @@
1718
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
1819

1920

21+
class _TokenResponseTooLarge(Exception):
22+
"""Raised when an Azure AD token response exceeds the bounded read limit."""
23+
24+
2025
class AzureDevOpsAuth(AuthProvider):
2126
"""Azure DevOps authentication provider.
2227
@@ -119,9 +124,38 @@ def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None:
119124
headers={"Content-Type": "application/x-www-form-urlencoded"},
120125
)
121126
try:
122-
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
123-
payload = _json.loads(resp.read().decode("utf-8"))
127+
from specify_cli.authentication.http import _StripAuthOnRedirect
128+
129+
def reject_token_redirect(_old_url: str, new_url: str) -> None:
130+
# A 307/308 redirect preserves this POST body, including the
131+
# client_secret. Refuse every redirect so credentials cannot
132+
# leave the fixed Microsoft token endpoint.
133+
raise urllib.error.URLError(
134+
f"Azure AD token request must not be redirected to {new_url}"
135+
)
136+
137+
opener = urllib.request.build_opener(
138+
_StripAuthOnRedirect((), reject_token_redirect)
139+
)
140+
with opener.open(req, timeout=30) as resp: # noqa: S310
141+
payload = _json.loads(
142+
read_response_limited(
143+
resp,
144+
max_bytes=MAX_JSON_METADATA_BYTES,
145+
error_type=_TokenResponseTooLarge,
146+
label="Azure DevOps token response",
147+
).decode("utf-8")
148+
)
124149
token = payload.get("access_token", "").strip()
125150
return token or None
126-
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
151+
except (
152+
urllib.error.URLError,
153+
OSError,
154+
_json.JSONDecodeError,
155+
UnicodeDecodeError,
156+
_TokenResponseTooLarge,
157+
):
158+
# Network failure, malformed JSON, or an oversized response — fall
159+
# through to the next strategy. Unrelated programming errors (other
160+
# ValueErrors, KeyErrors) intentionally propagate so they surface.
127161
return None

src/specify_cli/authentication/http.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +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
2021
from . import get_provider
2122
from .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:
6061
RedirectValidator = 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+
6377
class _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)

tests/http_helpers.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,46 @@
1-
"""HTTP test helpers shared by version-related CLI tests."""
1+
"""HTTP test helpers shared by CLI tests."""
22

3+
import io
34
import json
5+
import urllib.request
46
from unittest.mock import MagicMock
57

8+
import pytest
9+
610

711
def mock_urlopen_response(payload: dict) -> MagicMock:
812
"""Build a urlopen context-manager mock whose read returns JSON."""
913
body = json.dumps(payload).encode("utf-8")
1014
resp = MagicMock()
11-
resp.read.return_value = body
15+
resp.read.side_effect = io.BytesIO(body).read
1216
cm = MagicMock()
1317
cm.__enter__.return_value = resp
1418
cm.__exit__.return_value = False
1519
return cm
20+
21+
22+
@pytest.fixture(autouse=True)
23+
def route_opener_open_through_urlopen(monkeypatch):
24+
"""Route build_opener().open through urllib.request.urlopen.
25+
26+
``open_url(...)`` fetches via ``build_opener(...).open()``, which bypasses
27+
``urllib.request.urlopen`` — and with it the urlopen patches these test
28+
modules are built on.
29+
Delegating ``open()`` to urlopen at call time keeps those patches
30+
effective; the redirect handler's own behavior is covered by
31+
``TestRedirectStripping`` in test_authentication.py.
32+
33+
Import this fixture into a test module to activate it there.
34+
"""
35+
36+
class _UrlopenDelegatingOpener:
37+
def open(self, req, data=None, timeout=None):
38+
if data is None:
39+
return urllib.request.urlopen(req, timeout=timeout)
40+
return urllib.request.urlopen(req, data=data, timeout=timeout)
41+
42+
monkeypatch.setattr(
43+
urllib.request,
44+
"build_opener",
45+
lambda *handlers: _UrlopenDelegatingOpener(),
46+
)

tests/integrations/test_integration_catalog.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import pytest
77
import yaml
88

9+
from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401
10+
911
from specify_cli.integrations.catalog import (
1012
IntegrationCatalog,
1113
IntegrationCatalogEntry,

tests/self_upgrade_helpers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
_verify_upgrade,
1919
)
2020
from tests.conftest import strip_ansi
21-
from tests.http_helpers import mock_urlopen_response
21+
from tests.http_helpers import mock_urlopen_response, route_opener_open_through_urlopen
2222

2323
__all__ = (
2424
"SENTINEL_GH_TOKEN",
@@ -31,6 +31,7 @@
3131
"_verify_upgrade",
3232
"mock_urlopen_response",
3333
"requires_posix",
34+
"route_opener_open_through_urlopen",
3435
"runner",
3536
"strip_ansi",
3637
)

0 commit comments

Comments
 (0)