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:
- Extract a clean station-v0.73 tarball, sys.path.insert(0, "workbench").
- 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.)
- import routes.canvas as canvas
- 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 computingwant, and skip (or quarantine) any file whose id doesn't pass it, rather
than renaming to whatever the field says.