diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0ef8d44..1f87dc3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,10 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Restricted sandboxed web E2E readiness polling to standard-library-classified + loopback addresses and literal `localhost`, supporting the complete IPv4 + loopback block without admitting unspecified, external, or metadata-service + destinations. - Publish only the sanitized cumulative Strix report tree, avoiding a later copy of relative scanner output that could reintroduce known internal warning text into uploaded security evidence. diff --git a/docs/doctoring/sandboxed-web-readiness-loopback-boundary.md b/docs/doctoring/sandboxed-web-readiness-loopback-boundary.md new file mode 100644 index 000000000..12cb7caa7 --- /dev/null +++ b/docs/doctoring/sandboxed-web-readiness-loopback-boundary.md @@ -0,0 +1,35 @@ +# Sandboxed web readiness loopback boundary + +## Decision + +`sandboxed_web_e2e.py` polls only literal `localhost` or an IP address that +Python's standard-library `ipaddress` module classifies as loopback. Redirects +remain disabled. This supports the complete IPv4 loopback block, including +`127.0.0.2`, without admitting `0.0.0.0`, public addresses, link-local +metadata endpoints, or attacker-controlled DNS names. + +The boundary uses the standard library rather than a second address table. +It therefore follows the runtime's maintained special-address definitions and +keeps one fail-closed validation point before any network request. + +## Verification + +The regression exercises literal `localhost`, `127.0.0.1`, another address in +`127.0.0.0/8`, an unspecified address, a `.localhost` subdomain, a public +hostname, and the common cloud metadata address. Only the literal name avoids +another DNS resolution boundary. The existing no-redirect test continues to +prove that an allowed readiness endpoint cannot redirect the poller across the +boundary. + +## References + +Internet Assigned Numbers Authority. (2026). *IANA IPv4 special-purpose +address registry*. Retrieved August 23, 2026, from +https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml + +OWASP Foundation. (n.d.). *Server-side request forgery prevention cheat +sheet*. Retrieved August 23, 2026, from +https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html + +Python Software Foundation. (2026). *ipaddress — IPv4/IPv6 manipulation +library*. https://docs.python.org/3/library/ipaddress.html diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index ae0c3105a..17789a61e 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import ipaddress import json import os import signal @@ -13,6 +14,7 @@ import tempfile import time import urllib.error +import urllib.parse import urllib.request from collections.abc import Sequence from dataclasses import dataclass @@ -121,6 +123,16 @@ def wait_for_url(url: str, timeout: int, service: Service) -> bool: return True if not (url.startswith("http://") or url.startswith("https://")): raise ValueError(f"URL must start with http:// or https://, got: {url}") + + parsed = urllib.parse.urlparse(url) + hostname = (parsed.hostname or "").lower() + try: + is_loopback = hostname == "localhost" or ipaddress.ip_address(hostname).is_loopback + except ValueError: + is_loopback = False + if not is_loopback: + raise ValueError(f"URL cannot target external hostname: {hostname}") + deadline = time.monotonic() + timeout opener = urllib.request.build_opener(NoRedirectHandler()) while time.monotonic() < deadline: diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 6e092c293..e0ae61265 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -111,9 +111,19 @@ def test_wait_helpers_and_service_cleanup_edges(monkeypatch, tmp_path): exited_service = sandboxed_web_e2e.Service("done", "true", exited, tmp_path / "missing.log") assert sandboxed_web_e2e.wait_for_url("", 1, exited_service) is True + assert sandboxed_web_e2e.wait_for_url("http://localhost:1/", 1, exited_service) is False assert sandboxed_web_e2e.wait_for_url("http://127.0.0.1:1/", 1, exited_service) is False + assert sandboxed_web_e2e.wait_for_url("http://127.0.0.2:1/", 1, exited_service) is False with pytest.raises(ValueError, match="URL must start with http:// or https://"): sandboxed_web_e2e.wait_for_url("file:///etc/passwd", 1, exited_service) + with pytest.raises(ValueError, match="URL cannot target external hostname: example.com"): + sandboxed_web_e2e.wait_for_url("http://example.com/health", 1, exited_service) + with pytest.raises(ValueError, match="URL cannot target external hostname: app.localhost"): + sandboxed_web_e2e.wait_for_url("http://app.localhost:8000/health", 1, exited_service) + with pytest.raises(ValueError, match="URL cannot target external hostname: 169.254.169.254"): + sandboxed_web_e2e.wait_for_url("http://169.254.169.254/latest/meta-data/", 1, exited_service) + with pytest.raises(ValueError, match="URL cannot target external hostname: 0.0.0.0"): + sandboxed_web_e2e.wait_for_url("http://0.0.0.0:8000/health", 1, exited_service) sandboxed_web_e2e.stop_service(exited_service) assert sandboxed_web_e2e.tail_text(tmp_path / "missing.log") == ""