Files
hermes-agent/tests/tools/test_clawhub_zip_stream.py
Eugeniusz Gilewski fef98ff00f fix(skills): bound streamed ClawHub ZIP downloads (#57571)
ClawHub ZIP downloads buffered the entire response before applying member
limits. Stream the archive into a 25 MiB bounded buffer and enforce actual
received bytes even when Content-Length is absent or incorrect.

Use the existing SSRF-safe client with bounded redirects and recheck URL and
website policy at every hop. Close responses before retry delays, clamp
Retry-After, and stop after the third rate-limited response without attempting
ZIP extraction. Preserve member path validation and raw-file fallback.

Related #29450
Co-authored-by: sprmn <oncuevtv@gmail.com>
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-09-12 21:50:30 -07:00

111 lines
4.3 KiB
Python

"""Bound the archive before extraction, without buffering response.content."""
from contextlib import contextmanager
import io
import zipfile
import httpx
import pytest
from tools import skills_hub_clawhub as clawhub
def _archive():
data = io.BytesIO()
with zipfile.ZipFile(data, "w") as archive:
archive.writestr("SKILL.md", "# A normal skill")
return data.getvalue()
def _mock_download(monkeypatch, data, headers):
responses = []
@contextmanager
def stream(*args, **kwargs):
response = httpx.Response(200, headers=headers, stream=httpx.ByteStream(data))
responses.append(response)
try:
yield response
finally:
response.close()
monkeypatch.setattr(clawhub, "_guarded_http_stream", stream, raising=False)
monkeypatch.setattr(clawhub.httpx, "get", lambda *a, **k: httpx.Response(200, headers=headers, content=data))
return responses
@pytest.mark.parametrize("declared", [None, "1", "invalid", "-1"])
def test_actual_stream_size_enforces_cap_despite_header(monkeypatch, declared):
data = _archive()
_mock_download(monkeypatch, data, {} if declared is None else {"content-length": declared})
monkeypatch.setattr(clawhub.ClawHubSource, "ZIP_DOWNLOAD_MAX_BYTES", len(data) - 1, raising=False)
assert clawhub.ClawHubSource()._download_zip("example", "1") == {}
def test_exact_limit_stream_extracts_without_content_access_and_closes(monkeypatch):
data = _archive()
responses = _mock_download(monkeypatch, data, {})
monkeypatch.setattr(clawhub.ClawHubSource, "ZIP_DOWNLOAD_MAX_BYTES", len(data), raising=False)
assert clawhub.ClawHubSource()._download_zip("example", "1") == {"SKILL.md": "# A normal skill"}
assert len(responses) == 1 and responses[0].is_closed
def test_declared_oversize_does_not_read_body(monkeypatch):
responses = _mock_download(monkeypatch, b"", {"content-length": "101"})
monkeypatch.setattr(clawhub.ClawHubSource, "ZIP_DOWNLOAD_MAX_BYTES", 100)
monkeypatch.setattr(httpx.Response, "iter_bytes", lambda *a, **k: pytest.fail("read oversized body"))
assert clawhub.ClawHubSource()._download_zip("example", "1") == {}
assert responses[0].is_closed
def test_rate_limit_exhaustion_closes_responses_and_sleeps_only_between_attempts(monkeypatch):
responses = []
delays = []
@contextmanager
def stream(*args, **kwargs):
response = httpx.Response(429, headers={"retry-after": ["-1", "1000", "invalid"][len(responses)]})
responses.append(response)
try:
yield response
finally:
response.close()
monkeypatch.setattr(clawhub, "_guarded_http_stream", stream)
monkeypatch.setattr(clawhub.time, "sleep", delays.append)
assert clawhub.ClawHubSource()._download_zip("example", "1") == {}
assert delays == [0, 15]
assert len(responses) == 3 and all(response.is_closed for response in responses)
@pytest.mark.parametrize("destination", ["https://download.example/bundle", "http://127.0.0.1/private", "https://blocked.example/bundle"])
def test_stream_rechecks_redirect_policy_and_closes_clients(monkeypatch, destination):
from tools import skills_hub, url_safety
requests = []
clients = []
def respond(request):
requests.append(request)
if len(requests) == 1:
return httpx.Response(302, headers={"location": destination})
return httpx.Response(200, content=b"bundle")
def client(**kwargs):
result = httpx.Client(transport=httpx.MockTransport(respond), **kwargs)
clients.append(result)
return result
monkeypatch.setattr(url_safety, "create_ssrf_safe_client", client)
monkeypatch.setattr(skills_hub, "is_safe_url", lambda url: "127.0.0.1" not in url)
monkeypatch.setattr(skills_hub, "check_website_access", lambda url: {"host": "blocked.example", "rule": "test"} if "blocked.example" in url else None)
with skills_hub._guarded_http_stream("https://api.example/download", params={"slug": "secret"}) as response:
if "download.example" in destination:
assert response.read() == b"bundle"
assert str(requests[1].url) == destination
else:
assert response is None
assert len(requests) == 1
assert requests[0].url.params["slug"] == "secret"
assert clients and all(client.is_closed for client in clients)