← Community
bugfixed

Workflow-filename self-heal renames a file to an unsanitized JSON `id` -- path traversal via os.rename() moves it anywhere on disk

ShwetaShweta#137d ago · 40 views
affected: Station version: v0.73fixed in: station-v0.74

in v0.73: routes/canvas.py adds _normalize_workflow_filenames(wdir), which
scans every *.json file in WS/workflows/, reads each file's own id field,
and renames the file to <id>.json when the filename doesn't already match
-- meant to self-heal a workflow that was copied into the folder under the
wrong filename (a scenario the code's own comment describes: a shared/
downloaded workflow file pasted into WS/workflows/ whose filename differs
from the id inside it).

The id used to build the rename target is read straight out of the file's
own JSON content and is never validated or sanitized -- no filename-safe
regex, no path-separator stripping, nothing. It is passed directly into
os.path.join(wdir, str(id) + ".json") as the destination of os.rename().

Reproduction steps:

  1. Extract a clean station-v0.73 tarball, sys.path.insert(0, "workbench").
  2. Create WS/workflows/ and drop in ANY .json file (any filename) containing:

{"id": "../../some/outside/path", "kind": "canvas", "title": "..."}
(standing in for a shared workflow template a user copied into that
folder -- not something entered through the Save/Import UI.)

  1. import routes.canvas as canvas
  2. canvas._normalize_workflow_filenames("WS/workflows")

Expected: the file is either left alone or renamed to something that stays
inside WS/workflows/.

Actual: the file is moved (os.rename) outside the workflows directory
entirely, to whatever path the attacker-controlled id field encodes,
landing as <traversal-target>.json with the original file's full content
preserved at the new location.

This function is not an obscure edge path -- it runs automatically and
without any session/auth check on every hit to workflows_list() (called by
the plain, unauthenticated GET /api/workflows/list route, plus the Studio
dashboard's own periodic refresh and the policy tab), and is additionally
called explicitly from the Canvas Save and workflow-dispatch handlers. Simply
having the crafted file present in WS/workflows/ and letting the Studio UI
load is enough to trigger the move -- no explicit "run" or "import" action
required, and no confirmation is shown.

Because os.path.join()+os.rename() do not stop at the target directory
boundary, the achievable primitive is an arbitrary-path file move/write of
attacker-chosen JSON content, bounded only by the filesystem permissions of
the account running the station process (e.g. anywhere writable under the
user's home directory).

Root cause: workbench/routes/canvas.py, _normalize_workflow_filenames()
(~line 201-231):

for fn in entries:
rec = jload(os.path.join(wdir, fn), {})
rid = rec.get("id") if isinstance(rec, dict) else None
if not rid:
continue
want = str(rid) + ".json"
if fn == want or os.path.exists(os.path.join(wdir, want)):
continue
try:
os.rename(os.path.join(wdir, fn), os.path.join(wdir, want))
except OSError:
pass

rid is used verbatim with no re.match(r"^[A-Za-z0-9_-]{1,80}$", ...) check
-- the exact check the Canvas Save handler (_handle_save in
routes/dispatch_workflow.py) already applies to wf_id before it ever
touches a filename. This function is the one code path in the whole feature
that builds a filename from an id without that guard.

Suggested fix: apply the same filename-safe validation used everywhere else
in this feature (re.match(r"^[A-Za-z0-9_-]{1,80}$", rid)) before computing
want, and skip (or quarantine) any file whose id doesn't pass it, rather
than renaming to whatever the field says.

5 pts

1 reply

Confirmed — and thank you, this is a real one, on a same-day regression.

_normalize_workflow_filenames (new in station-v0.73) derived the rename target from the record's own id via str(rid) + ".json" and called os.rename(). So a workflow JSON with id: "../../evil" moved the file OUTSIDE WS/workflows/ on the next Workflows-tab load — path traversal, exactly as described.

Fix (committed, shipping in station-v0.74): the self-heal now only renames when the id is a safe filename token (^[A-Za-z0-9_-]{1,80}$ — the same charset the save handler already enforces on ids), plus a defense-in-depth check that the resolved destination stays inside the workflows dir. A record whose id isn't a safe token isn't a runnable rail anyway (save would have rejected it), so it's left untouched.

Verified: a ../../../../tmp/evil id is no longer moved and does not escape; a legitimate id still self-heals.

Sign in to reply.