Reproduction steps:
- Arm the real gate for a module that declares requires.network, e.g.
module_sandbox.install_restrictions(ns, {"network": ["api.stripe.com"]}, slug),
and run inside sandbox_active(slug).
(repro: repro_module_network_allowlist_stale_approved_host.py)
- CONTROL — with no prior HTTP call on the thread:
socket.socket().connect(("203.0.113.9", 9)) # non-allowlisted TEST-NET-3 IP
- EXPLOIT — construct one allowed HTTPConnection, then repeat the same dial:
http.client.HTTPConnection("api.stripe.com") # __init__ only, no real dial
socket.socket().connect(("203.0.113.9", 9)) # same non-allowlisted IP
Output:
allowlist enforced for this module: ['api.stripe.com']
CONTROL (no prior HTTP) -> BLOCKED by SandboxViolation
EXPLOIT (after HTTPConn) -> REACHED-DIAL (real socket error: TimeoutError)
203.0.113.9 is non-routable, so nothing real is contacted — a TimeoutError
instead of a SandboxViolation proves the gate did not fire before the dial.
Expected:
A module may only reach hosts in its declared requires.network allowlist. A raw
socket dial to a non-allowlisted host must raise SandboxViolation regardless of
any earlier allowed call.
Actual:
The socket-layer thread stash set by the HTTP layer is never reset on the
http.client path, so it leaks past the call that set it:
- _wrapped_urlopen (module_sandbox.py:336) sets _tls.approved_host at :354 and
CLEARS it in a finally at :357-358.
- _wrapped_httpconn_init (module_sandbox.py:360) sets
_tls.approved_host = _hostname_of(host) at :371 and returns at :372 — no reset.
- _guard_socket_addr (module_sandbox.py:374), on an IP-literal dial, reads that
stash at :390 (approved = getattr(_tls, "approved_host", None)) and validates
the STASHED name instead of the real target at :391 — so a dial to 203.0.113.9
is checked against "api.stripe.com" and passes.
urllib.request.urlopen self-heals via its finally, but requests/urllib3/direct
http.client never enter that wrapper — they reach the socket through
HTTPConnection.__init__ only. So after a module's first ordinary API call to its
allowed host, _tls.approved_host stays armed for the rest of the thread, and any
later raw socket.connect((ip, port)) to an arbitrary host passes. A module handed
a vault credential (its API key, an OAuth token, scraped data) can then open a
socket to an attacker host and exfiltrate it — the network-allowlist guarantee no
longer holds for the common case.
Scope (honest):
Requires an opted-in requires.network module and one prior HTTP call through
http.client/requests on the same thread (the normal case — that's how a module
reaches its declared API). A handler that exclusively uses urllib.request.urlopen
is not affected. Trust default "any" (self-signed bundles install) makes a hostile
module trivial to load.
Suggested fix:
- Make the stash single-use: in _guard_socket_addr, clear _tls.approved_host
right after an IP-literal dial consumes it (module_sandbox.py:390-393), so a
legit HTTP call's own connect spends it once and a later raw dial finds None
-> denied. Redirects/connection-pools re-arm via their own
HTTPConnection.__init__, so no false positives.
- Better: bind the approved host to the specific connection/socket rather than a
thread-global, so it can never outlive the request that set it.
Station version (railcall version): station-v1.5.8
Module slug + version: n/a — affects any requires.network module; repro arms a
synthetic poc/evil with network: ["api.stripe.com"]