← Community
bugopen

module_sandbox: HTTPConnection.__init__ leaves _tls.approved_host armed, so a later raw socket.connect bypasses the network allowlist

mrxstudio30mrxstudio3010h ago · 21 views
affected: station-v1.5.8

Reproduction steps:

  1. 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)

  1. CONTROL — with no prior HTTP call on the thread:

socket.socket().connect(("203.0.113.9", 9)) # non-allowlisted TEST-NET-3 IP

  1. 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:

PoC

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:

  1. 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.

  1. 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"]

0 replies

Sign in to reply.