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
+52 -8
View File
@@ -1,16 +1,20 @@
"""Tests for GitHub-authenticated HTTP request helpers."""
import io
import json
import os
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
from urllib.request import Request
import pytest
from specify_cli._github_http import (
GITHUB_HOSTS,
build_github_request,
resolve_github_release_asset_api_url,
)
from specify_cli.authentication.http import _StripAuthOnRedirect
class TestBuildGitHubRequest:
@@ -90,7 +94,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
@contextmanager
def fake_open(url, timeout=None, extra_headers=None):
resp = MagicMock()
resp.read.return_value = json.dumps(release_json).encode()
resp.read.side_effect = io.BytesIO(json.dumps(release_json).encode()).read
yield resp
return fake_open
@@ -198,7 +202,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
def capturing_open(url, timeout=None, extra_headers=None):
captured_urls.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({"assets": []}).encode()
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
yield resp
resolve_github_release_asset_api_url(
@@ -217,7 +221,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
def capturing_open(url, timeout=None, extra_headers=None):
captured_urls.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({"assets": []}).encode()
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
yield resp
resolve_github_release_asset_api_url(
@@ -260,7 +264,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
def recording_open(url, timeout=None, extra_headers=None):
called.append(url)
resp = MagicMock()
resp.read.return_value = b"{}"
resp.read.side_effect = io.BytesIO(b"{}").read
yield resp
result = resolve_github_release_asset_api_url(
@@ -299,7 +303,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
def recording_open(url, timeout=None, extra_headers=None):
called.append(url)
resp = MagicMock()
resp.read.return_value = b"{}"
resp.read.side_effect = io.BytesIO(b"{}").read
yield resp
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
@@ -317,7 +321,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
def capturing_open(url, timeout=None, extra_headers=None):
captured.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({"assets": []}).encode()
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
yield resp
resolve_github_release_asset_api_url(
@@ -344,10 +348,10 @@ class TestResolveGitHubReleaseAssetApiUrl:
def capturing_open(url, timeout=None, extra_headers=None):
captured.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({
resp.read.side_effect = io.BytesIO(json.dumps({
"assets": [{"name": "pack.zip",
"url": "https://api.github.com/repos/org/repo/releases/assets/99"}]
}).encode()
}).encode()).read
yield resp
result = resolve_github_release_asset_api_url(
@@ -357,3 +361,43 @@ class TestResolveGitHubReleaseAssetApiUrl:
)
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
class TestGitHubRedirectAuth:
"""Tests for GitHub-owned redirect auth handling."""
def test_multi_hop_github_redirect_preserves_unredirected_auth(self):
"""Auth survives a multi-hop redirect chain within GitHub hosts."""
handler = _StripAuthOnRedirect(tuple(GITHUB_HOSTS))
req1 = Request(
"https://github.com/org/repo",
headers={"Authorization": "Bearer tok"},
)
req2 = handler.redirect_request(
req1,
io.BytesIO(b""),
302,
"Found",
{},
"https://codeload.github.com/org/repo/zip",
)
assert req2 is not None
auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get(
"Authorization"
)
assert auth2 == "Bearer tok"
req3 = handler.redirect_request(
req2,
io.BytesIO(b""),
302,
"Found",
{},
"https://raw.githubusercontent.com/org/repo/main/file",
)
assert req3 is not None
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get(
"Authorization"
)
assert auth3 == "Bearer tok"