Skip to content
Closed
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions docs/doctoring/sandboxed-web-readiness-loopback-boundary.md
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions scripts/ci/sandboxed_web_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import argparse
import ipaddress
import json
import os
import signal
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions tests/test_sandboxed_web_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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") == ""

Expand Down
Loading