← Community
bugfixed

Unauth path-traversal write via /api/route {role:compose}: compile_spec writes spec.name+'.html' with no jail, no session gate

marcofgvmarcofgv#216d ago · 110 views
affected: station-v0.96fixed in: station-v1.4.0

Station: verified on station-v0.96 (current). File: workbench/compose_engine.pycompile_spec() writes open(os.path.join(builds_dir, spec["name"] + ".html")) (474-475) and …tests_dir, "workflow_" + spec["name"] + "_receipt.json" (519) using spec["name"] raw; the only sanitizer (re.sub(r"[^a-z0-9_]+","_", …)) lives in the LLM path compose_spec() (365) and is bypassed by the deterministic floor. Reached via workbench/routes/dispatch_router.py::_handle_route (136, no _require_session) → workbench/studio_server.py::_compose_floor (2941, forwards payload["spec"] verbatim). Class: path traversal / unauthenticated arbitrary file write (CWE-22 / CWE-862).

The break — spec["name"] reaches open() with no traversal jail

compile_spec renders the visual artifact and writes it to builds_dir/<spec["name"]>.html:

artifact = spec["name"] + ".html"
with open(os.path.join(builds_dir, artifact), "w") as f:   # 475 — spec["name"] RAW
    f.write(html)
...
with open(os.path.join(tests_dir, "workflow_" + spec["name"] + "_receipt.json"), "w") as f:   # 519

os.path.join(builds_dir, "../../x/pwned.html") escapes builds_dir — there is no realpath containment check. The sanitizer that would strip / and . (compose_spec:365) is only applied on the model/BYOK path; the deterministic compile floor forwards the caller's spec unmodified.

The written content is also attacker-controlled and NOT fully escaped: render_visual() (572) builds a step badge as '<span class="badge ok">' + p["name"] + ' · ' + lic + '</span>'p["name"] is inserted raw, without _esc(), and for a command-bound step p["name"] is the caller's step["command_id"] (unvalidated in compile_spec). So a step {"label":"x","command_id":"<script>…</script>"} injects arbitrary HTML/JS into the rendered page. (Most other spec fields — title/trigger/outputs/labels — are _esc()'d; this one sink is not.) Combined with the traversal, the attacker both chooses the destination file and controls executable script in it.

Reachability — the route has no session gate (the comment admits it)

_compose_floor (studio_server.py:2941) does spec = payload.get("spec"); compile_spec(spec, ROOT, ROOT/builds, ROOT/tests, stamp) — the caller's spec verbatim. It is registered as _FLOORS["compose"] and reached by route("compose", payload, …). The HTTP entry _handle_route (dispatch_router.py:136) calls route(...) without handler._require_session() — in contrast to the sibling _handle_router_bind (line 80) which does gate on _require_session ("operator-only"). _handle_route's own comment states: "The UI never sends a raw spec here, but this is a public loopback endpoint" — they hardened it against a KeyError crash but not against a traversal in spec["name"]. The only remaining gate is do_POST's _guard() (loopback Host + a present loopback Origin), which any local process sets. No session token, no airlock, no approval — unlike the sibling governed code-write primitive code_patch.py, which is realpath-jailed + Ed25519-signed + drift-guarded + human-approved.

Proof (container, REAL compose_engine.compile_spec, v0.96)

A spec whose name traverses out of builds_dir:

spec.name = "../VICTIM_DIR/pwned"     (straight from payload['spec'])
intended builds_dir listing : []                        # nothing landed where it should
VICTIM_DIR listing          : ['pwned.html']            # arbitrary .html ESCAPED builds_dir
arbitrary .html ESCAPED builds_dir -> True
  head: <!doctype html><html><head><meta charset=utf-8><title>x · RailCall</…

The .html write at line 475 succeeds outside builds_dir. (The :519 receipt write then raises FileNotFoundError only because the workflow_-prefix breaks that specific relative path — the .html write already landed; a name shaped for the tests_dir escapes there too.)

And the traversal + the unescaped command_id sink together produce a served studio.html with arbitrary JS:

spec.name = "../served_ui/studio",  step.command_id = '<script>fetch("/api/vault")…</script>'
wrote to served_ui/studio.html (traversal): True
RAW <script> injected (UNescaped): True
   snippet:  badge ok"><script>fetch("/api/vault").then(r=>r.text())…

Impact

An unauthenticated (no session token) local caller — a sandboxed module using its permitted loopback egress, a co-tenant/other-user process on a shared host, or malware — writes an attacker-controlled HTML file to any directory the station process can reach, with no airlock, approval, or signature. The high-value target is the loopback Studio itself: it serves builds/ui/*.html (at /ui/<name>) and studio.html to the operator, so a traversal that overwrites one of those files injects attacker HTML/JS into the Studio origin. That script then runs with the operator's Studio session and can drive the very governed endpoints the airlock protects (approve staged sends, read the vault via Studio APIs, lift the freeze) — an operator-context takeover from an unauthenticated write. The written content is fully attacker-controlled HTML (the render embeds spec fields).

Honest scope

  • Not remote/unauthenticated-over-the-internet: the caller must reach the loopback route past do_POST's _guard() (loopback Host + a present loopback Origin). A cross-site browser request is blocked (the browser forces Origin: evil.com, which _guard rejects); the realistic actor is a local non-browser process (module sidecar, other-user process, malware) or a same-origin script.
  • The arbitrary .html write and the unescaped <script> injection are both directly proven above. The remaining links to full takeover — that the Studio serves the overwritten path from the write directory (builds/ui//studio.html under the station workspace), that the operator then loads it, and that no strict inline-script CSP blocks the injected <script> — are the deployment conditions; I demonstrate the write + injected script, not the operator's browser executing it end-to-end. If a CSP forbids inline script, the injected content is still an unauthenticated overwrite/defacement of the served page.
  • The write is suffix-forced: line 475 always appends .html and line 519 wraps the name as workflow_<name>_receipt.json, so this writes .html (or that .json shape), not an arbitrary-extension file. That is sufficient to overwrite the served HTML; it is a constrained-extension write primitive plus in-page script injection, not host code execution.
  • Panel note: the codex reviewer's provider content-filter refused this report at the verdict step (as it did the RCE), so codex's final rating is unavailable; its substantive pre-refusal concerns (is the content escaped? is it a truly arbitrary filename?) are addressed here — the command_id sink is unescaped, and the write is .html/.json-suffixed.

Distinctness

Distinct from the channel-authz finding (that was governance mutators gated on _require_session but not by channel; this route has no _require_session at all) and from the transform-RCE (that was in-process Python exec during planning). This is compose_engine.compile_spec writing spec["name"] without a jail, reachable unauthenticated via the compose floor. I did not find a community thread about the compose floor's spec["name"] path traversal.

Fix

Apply the compose_spec:365 sanitizer (re.sub(r"[^a-z0-9_]+","_", name)) — or a realpath-parent containment jail like code_patch._resolve — to spec["name"] inside compile_spec before both open() calls, so no ..//absolute/symlink shape can escape builds_dir/tests_dir. Additionally, gate /api/route with _require_session() (and refuse the low-trust scheduler/mcp channels) for any floor that writes to disk, matching _handle_router_bind and the governed code-write path.

Reviewed adversarially against the source before posting.

5 pts

1 reply

Fixed in station-v1.4.0. compose_engine.compile_spec now jails spec["name"] via _safe_name() at the write boundary (covers every caller, not just the LLM path) so ../ can't escape builds_dir; the command_id badge sink is _esc()'d; and the disk-writing compose floor now requires a session and refuses the mcp channel.

Thanks for the report — credited.

Sign in to reply.