Skip to content

0041. Retest scope drives the sandbox target: allowlisting egress proxy for online targets

Date: 2026-07-24 Status: superseded by ADR-0045 (the L7 proxy is replaced by an L3 egress gateway so all tools, not just HTTP, reach the scoped host)

Context

The agentic retest sandbox (ADR-0025) has always targeted the lab: its --internal per-session network attaches exactly one container, revalid-juice-shop, and nothing else — no gateway, no route off the bridge (ADR-0033 made FR-06 network membership rather than an HTTP allowlist). That is an excellent containment story, but it means the tool can only ever retest the lab. A real revalidation has to hit the actual target the report is about — an online host — which the current sandbox physically cannot reach by construction.

Álvaro's direction (issue #208): the whitelisted scope must come from the finding (the target the operator writes before launch, already captured as target_set), and it must be parsed to its host — a finding about https://domain.com/#/login retests domain.com at any path, not just the one page. And it must work against online targets, not only the lab.

The load-bearing decision is how egress is bounded once the sandbox is no longer lab-only. --internal (physically no internet) cannot reach domain.com. Álvaro chose the allowlisting egress proxy over a firewall IP-allowlist or open operator-validated egress.

Decision

Scope → host. revalid.scope.scope_host parses each scope endpoint to its host (host or host:port), dropping scheme/path/query/fragment (incl. SPA hash routes), preserving sub-domains and port, lower-cased. scope_hosts de-dupes a scope's endpoints. The sandbox is provisioned against these hosts.

Two provisioning modes, chosen from the scope.

  • Lab mode (unchanged). When the scope is empty or resolves to the lab (localhost:3000 / the revalid-juice-shop container), provision exactly as today: an internal=True network with the lab container as its only member. Existing behaviour, tests and the egress-lock system test are untouched.
  • Online mode (new). Otherwise the target is an online host. Provision:
  • a per-session internal=True network — the sandbox still has no direct route to the internet;
  • an egress-proxy container attached to both that internal network and an external (internet-capable) network, configured to allow only the scoped host(s) and deny everything else;
  • the sandbox container on the internal network with HTTP_PROXY/HTTPS_PROXY pointed at the proxy — so its only route to the internet is HTTP(S) through the allowlist.

The containment guarantee is preserved, just narrowed from "the lab" to "the operator-declared scope": the sandbox can reach the scoped host(s) over HTTP(S) and nothing else. Non-HTTP egress (raw TCP scans, etc.) has no route out in online mode — it fails closed rather than reaching an unlisted host. This is stated plainly, not papered over: online retests are HTTP(S) revalidation; a full network scan of an online target is out of scope for this containment model.

Scope stays set-once. The hosts are fixed when the sandbox is provisioned (one target_set); changing scope still needs a fresh session — unchanged.

Alternatives considered

  • Firewall IP-allowlist (resolve the host, iptables-allow those IPs). Rejected: brittle against DNS changes, CDNs and IP rotation — it either breaks legitimate access or silently lets traffic leak to co-resolved IPs; a host allowlist at the proxy is what the operator actually declared.
  • Open egress, operator-validated scope (drop --internal, advisory scope). Rejected: it discards the "physically cannot reach anything unlisted" guarantee entirely — the weakest containment and the biggest departure from the thesis's safety story.
  • Keep lab-only. Rejected: it makes real online revalidation impossible, which is the whole point of #208.

Consequences

  • Easier: real online-target retests, with the scope taken from the finding and enforced as an egress allowlist; the lab path is byte-for-byte unchanged.
  • Security posture: the containment claim shifts from "lab only, no internet" to "the operator-declared scope only, over HTTP(S)". It is still an enforced boundary (the proxy is the sole route out), not advisory — a weaker guarantee than lab-only, honestly narrower, but not open internet.
  • New surface: a proxy container image + per-session config, a second (external) network in online mode, scope.py, and scope threaded from the finding through the sandbox factory. FR-06 is re-broadened from "network membership" to "network membership (lab) or a host-allowlisting egress proxy (online)".
  • Accepted limits: non-HTTP egress is unavailable online (fails closed); proxy allowlisting is host-based (a host that fronts many origins via one name is trusted as a unit). The live online path is validated by a system test (Docker + the sandbox extra), like the existing egress lock.
  • Supersedes the "the only reachable target is revalid-juice-shop" absolute in ADR-0025/0033 for online scope; the lab absolute still holds for lab scope.

Update (2026-07-25, issue #226) — the proxy path was broken from the start

This ADR shipped with DockerSandbox._start_online marked # pragma: no cover and its live validation deferred to "a follow-up system test", which was never written. The path had two independent, fatal defects, and no online-scope retest could ever reach its target:

  1. The Squid config was interpolated as printf '%s' {config!r}. Python's repr escapes newlines to the two characters \n, and printf '%s' does not interpret escapes, so Squid was handed a single-line file of literal \n.
  2. The shell was passed as command, but the Squid images declare ENTRYPOINT ["entrypoint.sh"] — so it arrived as arguments to Squid itself, which exited with FATAL: '-c': unrecognized option.

Both are fixed: the config is passed base64-encoded (immune to any shell quoting rule, whatever a hostname contains), and the shell is passed as entrypoint. The decision in this ADR is unchanged — only its implementation was wrong.

Now validated live, against a real external host rather than in principle: from a sandbox scoped to www.hackthissite.org, the target returned HTTP 200 at both / and a deeper path, an off-scope http://google.com was refused by the proxy with 403 Forbidden (proving the proxy is alive and denying, not merely absent), and off-scope HTTPS was refused at CONNECT. Two unit regression tests pin the defects that hid here: one asserts the decoded config has real newlines, the other that the proxy is launched via entrypoint.

The lesson worth keeping: # pragma: no cover on a provisioning path meant a security-relevant component could ship completely non-functional while every gate stayed green. The pragma is still right — this code drives a live daemon — but the seams around it are now unit-tested.