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)
This commit is contained in:
Pascal THUET
2026-07-22 11:08:32 -05:00
committed by GitHub
parent 0f6ea64a03
commit 5601830ba3
18 changed files with 592 additions and 115 deletions
+10 -3
View File
@@ -4,8 +4,8 @@ Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
release tag. The ``self_app`` Typer sub-command group is co-located here so
all version-related logic lives in one place.
Dependencies: stdlib + packaging + ._console only (no other internal imports
at module level, keeping this layer thin and circular-import-safe).
Dependencies: stdlib + packaging + ._console + ._download_security only
(keeping this layer thin and circular-import-safe).
"""
from __future__ import annotations
@@ -28,6 +28,7 @@ from pathlib import Path
import typer
from packaging.version import InvalidVersion, Version
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from ._console import console
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]:
timeout=5,
extra_headers={"Accept": "application/vnd.github+json"},
) as resp:
payload = json.loads(resp.read().decode("utf-8"))
payload = json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_METADATA_BYTES,
label="GitHub latest release",
).decode("utf-8")
)
tag = payload.get("tag_name")
if not isinstance(tag, str) or not tag:
raise ValueError("GitHub API response missing valid tag_name")