From dfd2ab4036d02c610094ecd660c2a29604b1d6aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 20:47:03 +0000 Subject: [PATCH] Restrict refresh_remote to bounded HTTP(S) fetches refresh_remote passed the caller URL straight to urllib and buffered the whole response, so a file:// or ftp:// URL was silently accepted and a hostile/misbehaving policy server could stream unbounded data into memory before validation. Reject non-HTTP(S) schemes up front and cap the response body at 1 MiB. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DDSofWX5HwawsrwbN2nSXR --- pyisolate/policy/__init__.py | 20 ++++++++++++++++-- tests/test_remote_policy.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/pyisolate/policy/__init__.py b/pyisolate/policy/__init__.py index 803ebf4..e1bec75 100644 --- a/pyisolate/policy/__init__.py +++ b/pyisolate/policy/__init__.py @@ -4,6 +4,7 @@ import os import socket import tempfile +import urllib.parse import urllib.request from collections.abc import Iterable, Mapping from dataclasses import dataclass, field @@ -276,6 +277,11 @@ def _is_timeout_error(exc: Exception) -> bool: return False +# Policy documents are small YAML files; the bound exists because the response +# body comes from the network and is buffered in full before validation. +_MAX_REMOTE_POLICY_BYTES = 1 << 20 # 1 MiB + + def refresh_remote( url: str, token: str, @@ -284,13 +290,23 @@ def refresh_remote( *, dry_run: bool = False, ): - """Fetch policy YAML from *url* and apply it.""" + """Fetch policy YAML from *url* over HTTP(S) and apply it.""" + scheme = urllib.parse.urlsplit(url).scheme.lower() + if scheme not in {"http", "https"}: + raise ValueError(f"policy URL scheme must be http or https, got {scheme!r}") + attempts = max(1, max_retries + 1) for attempt in range(attempts): try: with urllib.request.urlopen(url, timeout=timeout) as fh: - text = fh.read().decode("utf-8") + raw = fh.read(_MAX_REMOTE_POLICY_BYTES + 1) + if len(raw) > _MAX_REMOTE_POLICY_BYTES: + raise ValueError( + f"remote policy from {url} exceeds " + f"{_MAX_REMOTE_POLICY_BYTES} bytes" + ) + text = raw.decode("utf-8") break except Exception as exc: # narrow to timeout conditions only if _is_timeout_error(exc): diff --git a/tests/test_remote_policy.py b/tests/test_remote_policy.py index f8158b4..c592912 100644 --- a/tests/test_remote_policy.py +++ b/tests/test_remote_policy.py @@ -127,3 +127,44 @@ def test_refresh_remote_timeout_error(tmp_path): finally: httpd.shutdown() thread.join() + + +def test_refresh_remote_rejects_non_http_scheme(tmp_path): + # urllib would happily open file:// (or ftp://) URLs; the policy fetch is + # documented as HTTP and must not turn into a local-file read primitive. + doc = tmp_path / "policy.yml" + doc.write_text("version: 0.1\n", encoding="utf-8") + + with pytest.raises(ValueError, match="scheme"): + policy.refresh_remote(doc.as_uri(), token="tok") + + +class OversizedHandler(BaseHTTPRequestHandler): + def do_GET(self): + body = b"#" * (policy._MAX_REMOTE_POLICY_BYTES + 1) + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + try: + self.wfile.write(body) + except BrokenPipeError: # pragma: no cover - client may bail early + pass + + def log_message(self, format, *args): # pragma: no cover - quiet test output + return + + +def test_refresh_remote_rejects_oversized_response(): + addr = ("127.0.0.1", 0) + httpd = HTTPServer(addr, OversizedHandler) + port = httpd.server_address[1] + + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + with pytest.raises(ValueError, match="exceeds"): + policy.refresh_remote(f"http://127.0.0.1:{port}", token="tok") + finally: + httpd.shutdown() + thread.join()