mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* 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)
69 lines
2.4 KiB
Python
69 lines
2.4 KiB
Python
"""Tests for bounded HTTP download helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from specify_cli._download_security import (
|
|
is_https_or_localhost_http,
|
|
read_response_limited,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url, allowed",
|
|
[
|
|
("https://example.com/preset.zip", True),
|
|
("http://localhost:8000/preset.zip", True),
|
|
("http://127.0.0.1/preset.zip", True),
|
|
("http://[::1]/preset.zip", True),
|
|
# Non-loopback HTTP is rejected.
|
|
("http://example.com/preset.zip", False),
|
|
# Loopback allowance is an exact-string match: 127.0.0.2 is not covered.
|
|
("http://127.0.0.2/preset.zip", False),
|
|
# A hostname is always required, even for HTTPS.
|
|
("https:///preset.zip", False),
|
|
("https://", False),
|
|
],
|
|
)
|
|
def test_is_https_or_localhost_http(url, allowed):
|
|
assert is_https_or_localhost_http(url) is allowed
|
|
|
|
|
|
class _Response:
|
|
"""Faithful stream stand-in: read() advances a cursor and returns b"" at EOF."""
|
|
|
|
def __init__(self, data: bytes, *, chunk: int | None = None):
|
|
self.data = data
|
|
self.pos = 0
|
|
# When set, never return more than *chunk* bytes per call even if more is
|
|
# requested - simulates short reads (e.g. chunked transfer encoding).
|
|
self.chunk = chunk
|
|
|
|
def read(self, size: int = -1) -> bytes:
|
|
if size < 0:
|
|
size = len(self.data) - self.pos
|
|
if self.chunk is not None:
|
|
size = min(size, self.chunk)
|
|
out = self.data[self.pos : self.pos + size]
|
|
self.pos += len(out)
|
|
return out
|
|
|
|
|
|
def test_read_response_limited_rejects_oversized_download():
|
|
with pytest.raises(ValueError, match="exceeds maximum size"):
|
|
read_response_limited(_Response(b"abcde"), max_bytes=4)
|
|
|
|
|
|
def test_read_response_limited_returns_full_body_within_limit():
|
|
assert read_response_limited(_Response(b"abcde"), max_bytes=10) == b"abcde"
|
|
|
|
|
|
def test_read_response_limited_enforces_bound_under_short_reads():
|
|
# A server that streams more than max_bytes total while every read() returns
|
|
# fewer bytes than requested (chunked encoding) must still be rejected - a
|
|
# single read(max_bytes + 1) could be fooled, the accumulating loop cannot.
|
|
response = _Response(b"x" * 100, chunk=8)
|
|
with pytest.raises(ValueError, match="exceeds maximum size"):
|
|
read_response_limited(response, max_bytes=16)
|