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 18:08:32 +02:00
committed by GitHub
parent 0f6ea64a03
commit 5601830ba3
18 changed files with 592 additions and 115 deletions

View File

@@ -8,6 +8,7 @@ import os
import subprocess
from typing import TYPE_CHECKING
from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from .base import AuthProvider
if TYPE_CHECKING:
@@ -17,6 +18,10 @@ if TYPE_CHECKING:
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
class _TokenResponseTooLarge(Exception):
"""Raised when an Azure AD token response exceeds the bounded read limit."""
class AzureDevOpsAuth(AuthProvider):
"""Azure DevOps authentication provider.
@@ -119,9 +124,38 @@ class AzureDevOpsAuth(AuthProvider):
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
payload = _json.loads(resp.read().decode("utf-8"))
from specify_cli.authentication.http import _StripAuthOnRedirect
def reject_token_redirect(_old_url: str, new_url: str) -> None:
# A 307/308 redirect preserves this POST body, including the
# client_secret. Refuse every redirect so credentials cannot
# leave the fixed Microsoft token endpoint.
raise urllib.error.URLError(
f"Azure AD token request must not be redirected to {new_url}"
)
opener = urllib.request.build_opener(
_StripAuthOnRedirect((), reject_token_redirect)
)
with opener.open(req, timeout=30) as resp: # noqa: S310
payload = _json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_METADATA_BYTES,
error_type=_TokenResponseTooLarge,
label="Azure DevOps token response",
).decode("utf-8")
)
token = payload.get("access_token", "").strip()
return token or None
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
except (
urllib.error.URLError,
OSError,
_json.JSONDecodeError,
UnicodeDecodeError,
_TokenResponseTooLarge,
):
# Network failure, malformed JSON, or an oversized response — fall
# through to the next strategy. Unrelated programming errors (other
# ValueErrors, KeyErrors) intentionally propagate so they surface.
return None