GET /v2 leaks the live SESSION_TOKEN to any genuine loopback TCP client with no session gate at all — os_sandbox.py's Linux backend explicitly allows a "contained" adversarial module to reach it (v1.3.1)
Reproduction steps:
- bugs_found_v1.3.1.txt findings #1/#3 already proved a FORGED-Host-header
variant of this leak, requiring the RAILCALL_STUDIO_HOST=0.0.0.0 LAN-bind
opt-in and an attacker reachable over the LAN. This finding shows the
underlying leak is broader and needs neither: on the platform's DEFAULT
configuration (RAILCALL_STUDIO_HOST unset — bind stays 127.0.0.1 only, no
RAILCALL_STUDIO_ALLOWED_HOSTS set), studio_server.py's do_GET (line 6859)
calls ONLY self._guard() (line 6860) before dispatching — never
_require_session(). H._guard() (line 6534) requires the Host header to be
a member of ALLOWED_HOSTS (which unconditionally includes "127.0.0.1" and
"127.0.0.1:<port>", regardless of any opt-in), and requires Origin/Referer
ONLY for POST requests (line 6544) — a plain GET carries neither
requirement. GET /v2 (lines 6876-6886) templates the live token directly
into the response: `body = f.read().replace(b"%RC_SESSION_TOKEN%",
SESSION_TOKEN.encode())`.
- Any TCP client that is not a browser (so Same-Origin-Policy/CORS, which
WOULD block a malicious webpage's fetch() from reading a cross-origin
response body, does not apply) and can open a socket to 127.0.0.1:<port>
satisfies _guard() trivially — a genuine loopback connection naturally
carries Host: 127.0.0.1:<port>, no forgery needed at all, since Host
reflects the connection's real target.
- Run repro_os_sandbox_loopback_session_token_exfil_v131.py against a clean
v1.3.1 extraction. It boots the REAL _Srv/H server on its DEFAULT bind
(127.0.0.1, no env overrides), connects over genuine 127.0.0.1, and sends
a plain GET /v2 with an unforged Host header, no Origin, no session
token presented anywhere:
status: HTTP/1.0 200 OK
session token found in served HTML: 0e535f99... (matches the real
in-memory SESSION_TOKEN exactly)
CONFIRMED
- Compounding angle — workbench/primitives/os_sandbox.py, whose entire
purpose is "OS-level containment for adversarial modules... [closing] the
gap where a module bypasses the env-var HTTPS_PROXY by opening a raw
socket." Its Linux backend, _wrap_systemd_run (line 241), grants
--property=IPAddressAllow=127.0.0.1/8 — by the function's OWN docstring:
"the sandbox does not need to know the specific port: it allows the whole
127.0.0.1/8 range... other loopback services (a Postgres on
127.0.0.1:5432) are still reachable, which matches the sandbox's scope —
we're stopping network EXFIL, not internal service access." Studio's own
API (the loopback service that issues and enforces the operator's session)
is exactly such a reachable "other loopback service" — and, per steps 1-3
above, it is not merely "reachable," it hands over the master credential
to any process that asks. A module confined ONLY by this Linux sandbox
backend can therefore steal the live session token via GET /v2, then (per
bugs_found_v1.3.1.txt finding #3's own proof against POST /api/freeze) use
it to call any _require_session()-gated endpoint — full API compromise
from inside the one containment layer whose specific job is stopping an
adversarial module's network exfil. (The macOS backend, _wrap_sandbox_exec,
correctly scopes network-outbound to the CONNECT broker's specific port
only — (remote ip "localhost:{broker_port}") — so this compounding path
is Linux/systemd-run-specific; macOS's narrower profile does not reproduce
it.)
Expected:
Every route that serves the live session token must require the session (or
an equally strong proof of legitimate access) to be presented already, not
merely that the request's Host header names the loopback address — a
value ANY loopback-reachable TCP client can supply truthfully. Separately, a
sandbox whose stated purpose is containing an adversarial module's network
reach should not leave the single most valuable exfil target on the machine
(the operator's live API credential) inside its allowed range.
Actual:
GET /v2 (and its siblings /studio_v2.html and /durable-demo, which template
the same token the same way) has no session requirement of any kind — only
the Host-based DNS-rebinding guard, which every genuine loopback client
satisfies by construction. Combined with os_sandbox.py's Linux backend
allowing the whole loopback range, "contained" is not an accurate description
of what that sandbox achieves against this specific, highest-value target.
Suggested fix:
Two independent, complementary fixes:
1. In studio_server.py, require a valid session (or explicitly design /v2
as the ONE legitimate token-issuance point, gated by something stronger
than a Host header — e.g. a one-time bootstrap nonce set at process
launch and consumed on first load) rather than relying on Host alone to
distinguish "the operator's own browser" from "any other loopback
process." At minimum, apply the SAME Origin/Referer requirement already
used for POST requests to this specific GET route, and reject any
request whose Origin/Referer doesn't match a real browser navigation.
2. In os_sandbox.py's _wrap_systemd_run, do not allow the whole
127.0.0.1/8 range — allow only the specific CONNECT broker port, the
same precision the macOS backend already achieves via `(remote ip
"localhost:{broker_port}")`. Newer systemd's SocketBindDeny (256+,
mentioned in this function's own comment as "revisit when broadly
available") is the natural mechanism once available broadly enough to
require; until then, prefer a network namespace (netns) + veth pair
scoped to exactly the broker port over the coarser IPAddressAllow range,
even at the cost of the UDS-bridge complexity the file's header
currently avoids.