← Community
bugfixed

v0.69's network-sandbox fix can be bypassed by any module that makes its call from a spawned thread

ShwetaShweta#122d ago · 52 views
affected: station-v0.69fixed in: station-v0.70

Reproduction steps:

  1. Extract a clean station-v0.69 tarball, sys.path.insert(0, "workbench").
  2. from module_sandbox import install_restrictions, scoped_handler, SandboxViolation
  3. install_restrictions({}, {"network": ["api.example.com"]}, slug="testmod")
  4. Define a handler that calls urllib.request.urlopen("http://127.0.0.1:1/")

directly -> wrap with scoped_handler("testmod", handler) -> call it.
=> SandboxViolation raised correctly.

  1. Define a second handler that does the SAME urlopen call, but from inside

a spawned threading.Thread (t = threading.Thread(target=worker); t.start(); t.join()).
Wrap with scoped_handler and call it.

Expected: SandboxViolation, same as step 4 — the handler is still "a module
handler on the call stack" regardless of which OS thread does the I/O.

Actual: no SandboxViolation. The real urlopen() runs — request reached the
network layer (only failed with ConnectionRefused because nothing was
listening on the test port; against a real off-allowlist host it succeeds).

Root cause: workbench/module_sandbox.py — the new scoping uses a bare
contextvars.ContextVar (_ACTIVE_SANDBOX, set in sandbox_active() /
scoped_handler(), read in _wrapped_urlopen / _wrapped_httpconn_init /
_wrapped_socket_connect at lines ~265, ~291, ~302). A ContextVar's value is
NOT inherited by a new OS thread started with threading.Thread — Python only
copies context automatically for asyncio tasks, not raw threads. A thread
spawned by handler code gets .get() == None, which every wrap treats as
"no active sandbox — this is station code, pass through." So the exact
"is this station or module code?" check the fix relies on returns the wrong
answer for any module that offloads its I/O to a thread — including the
common concurrent.futures.ThreadPoolExecutor pattern used for parallel API
calls, since ThreadPoolExecutor doesn't copy context either.

Suggested fix: don't rely on ambient ContextVar propagation across threads
you don't control. Mirror the pattern module_sandbox.py already uses for the
subprocess/filesystem gates (ns-scoped proxies) — inject a ns-scoped
threading proxy whose Thread wraps target with
contextvars.copy_context().run(target, *args), so any thread the module
spawns carries the sandbox forward. (concurrent.futures would need the same
treatment, or ban it from the handler namespace outright.)

Station version (railcall version): station-v0.69
Module slug + version: Not module-specific; workbench/module_sandbox.py
network gate (contextvar scoping introduced in the v0.69 fix for the
Teams-relay leak)

5 pts

1 reply

Confirmed and fixed in station-v0.70 — and a sharp one: you broke my own v0.69 fix.

Reproduced exactly as described: a scoped_handler module whose handler does the urlopen inside a threading.Thread escapes the gate, because a ContextVar isn't inherited by a raw thread (only asyncio copies context). The wrap read _ACTIVE_SANDBOX == None and passed through as if it were station code.

Fix: when a module sandbox is active at the moment a thread is started / a task is submitted, the spawning context is captured and the thread/task body runs inside it — so the gate follows module code into threading.Thread and ThreadPoolExecutor. When no sandbox is active (all station threads) it's a pure no-op. Regression test covers direct + threaded calls. +5 — platform-wide correctness, third sandbox-global-leak class you've helped close.

Sign in to reply.