Files
github-spec-kit/src/specify_cli/_download_security.py
Pascal THUET 5601830ba3 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)
2026-07-22 11:08:32 -05:00

90 lines
3.5 KiB
Python

"""Helpers for bounded HTTP downloads."""
from __future__ import annotations
from typing import NoReturn, TypeVar
from urllib.parse import urlparse
ErrorT = TypeVar("ErrorT", bound=Exception)
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
READ_CHUNK_SIZE = 1024 * 1024
# Tighter ceiling for responses that are read fully into memory and parsed as
# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
# downloads; JSON metadata responses are far smaller, so capping them close to
# their real size shrinks the memory-DoS surface and keeps the "too large"
# error reachable (rather than only triggering on tens of MiB). Pass it
# explicitly at each JSON call site so the intended bound is pinned there.
# METADATA covers fixed-shape single-object responses (an OAuth token, one
# release's metadata): a few KiB in practice, 1 MiB is already generous.
MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
_LOOPBACK_HOSTS = frozenset(("localhost", "127.0.0.1", "::1"))
def is_loopback_url(url: str) -> bool:
"""Return whether *url* targets an explicitly allowed loopback host."""
return urlparse(url).hostname in _LOOPBACK_HOSTS
def is_https_or_localhost_http(url: str) -> bool:
"""Return True if *url* is HTTPS, or HTTP limited to loopback hosts.
Shared scheme-safety predicate used by the auth HTTP redirect handler and
by the direct URL validations in the CLI download flows, so the rule (and
any future tightening of it) lives in one place.
A hostname is always required: a URL without one (e.g. ``https:///x``)
has no real target and is rejected regardless of scheme.
The loopback allowance is a deliberate *exact-string* match on
``localhost`` / ``127.0.0.1`` / ``::1``, not an IP-range check: other
loopback addresses (e.g. ``127.0.0.2``) are intentionally not covered.
``urlparse`` already lower-cases the hostname, so the comparison is
case-insensitive.
"""
parsed = urlparse(url)
if not parsed.hostname:
return False
is_localhost = parsed.hostname in _LOOPBACK_HOSTS
return parsed.scheme == "https" or (parsed.scheme == "http" and is_localhost)
def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
raise error_type(message)
def read_response_limited(
response,
*,
max_bytes: int = MAX_DOWNLOAD_BYTES,
error_type: type[ErrorT] = ValueError,
label: str = "download",
) -> bytes:
"""Read at most *max_bytes* from a response object.
``response.read(n)`` is only guaranteed to return *up to* ``n`` bytes and may
return fewer even when more data is pending (e.g. chunked transfer encoding),
so a single ``read(max_bytes + 1)`` cannot enforce the bound on its own. Read
in a loop until EOF or until one byte past the limit has been accumulated.
*max_bytes* is keyword-only. It defaults to the module-wide
``MAX_DOWNLOAD_BYTES`` (50 MiB) ceiling for archive/payload downloads;
callers with a tighter budget (e.g. small JSON responses) should pass an
explicit value so the intended bound is pinned at the call site rather than
tracking changes to the shared default.
"""
chunks: list[bytes] = []
total = 0
limit = max_bytes + 1
while total < limit:
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total > max_bytes:
_raise(error_type, f"{label} exceeds maximum size of {max_bytes} bytes")
return b"".join(chunks)