mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user