From d222d92c2b07383749425497e2f9be9b041b4b90 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Mon, 27 Jul 2026 22:28:50 +0500 Subject: [PATCH] fix: use bounded read for bundle download HTTP responses The bundle download used unbounded resp.read() to read HTTP responses into memory. A malicious or misconfigured catalog server could return an arbitrarily large payload causing OOM. Replace with read_response_limited() capped at MAX_DOWNLOAD_BYTES (50 MiB), consistent with how other download paths in the codebase enforce bounded reads. Add regression test that monkeypatches MAX_DOWNLOAD_BYTES to 100 bytes and verifies oversized responses are rejected. --- src/specify_cli/commands/bundle/__init__.py | 3 ++- tests/contract/test_bundle_cli.py | 30 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index d8fb22e511..4980ffc38a 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -13,6 +13,7 @@ import typer +from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited from ..._console import console, err_console from ...bundler import BundlerError from ...bundler.lib.project import ( @@ -879,7 +880,7 @@ def _validate_redirect(old_url: str, new_url: str) -> None: extra_headers=extra_headers, ) as resp: _require_https(f"bundle '{entry_id}'", resp.geturl()) - raw = resp.read() + raw = read_response_limited(resp, max_bytes=MAX_DOWNLOAD_BYTES) except BundlerError: raise except Exception as exc: # noqa: BLE001 diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index db2bb0c948..9215035228 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -767,3 +767,33 @@ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None payload = json.loads(result.output) assert payload["id"] == "demo-bundle" + + +def test_bundle_download_rejects_oversized_response(project: Path, monkeypatch): + """Bundle download rejects responses exceeding MAX_DOWNLOAD_BYTES.""" + # Monkeypatch to a small limit so the test is fast and low-memory. + monkeypatch.setattr( + "specify_cli.commands.bundle.MAX_DOWNLOAD_BYTES", 100 + ) + + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + # Return a response that exceeds 100 bytes. + return FakeBundleResponse(b"x" * 200, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + # Must fail with a size-limit error, not an unhandled traceback. + assert result.exit_code == 1 + # Rich may wrap the message across lines; normalise whitespace before checking. + output_flat = " ".join(result.output.split()) + assert "exceeds maximum size of 100 bytes" in output_flat