Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions pyisolate/policy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand Down
41 changes: 41 additions & 0 deletions tests/test_remote_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading