Reproduction steps:
- Any authenticated Studio session (the endpoint is _require_session-gated;
against the local Studio server this is also reachable cross-site, the Studio
CSRF surface — no token protects it).
- POST /api/workflow/delete with a traversal id, and confirm set equal to it:
{"id": "../../keys.local", "confirm": "../../keys.local"}
(the handler re-appends ".json"; "../../keys.local" targets WS/keys.local.json,
the live-key store — any relative path works, including out of WS entirely).
- Run repro_workflow_delete_path_traversal_v099.py against a clean v0.99
extraction. It drives the REAL _handle_delete (warming the _LATE names exactly
as studio_server does at startup, so now_iso/_persist_run_receipt resolve as in
the live server) and deletes a *.json OUTSIDE WS/workflows/. Output:
response: (200, {'ok': True, 'status': 'deleted', ...})
CONFIRMED — a file OUTSIDE workflows/ (keys.local.json) was removed.
Expected:
/api/workflow/delete should refuse an id that is not a filename-safe token —
the SAME guard its sibling endpoints already enforce:
_handle_save (line 211), _handle_dag_run (574), _handle_run (667),
workflow GET (1835/1921) all do:
if not re.match(r"^[A-Za-z0-9_-]{1,80}$", wf_id): return error
Deletion must be confined to WS/workflows/<id>.json for a validated id.
Actual:
_handle_delete (routes/dispatch_workflow.py:303-314) omits that guard:
wf_id = str((body or {}).get("id", "")) # NO regex
confirm = str((body or {}).get("confirm", ""))
cp = os.path.join(WS, "workflows", wf_id + ".json")
if not wf_id or not os.path.isfile(cp): return "no such composed rail"
...
rec = jload(cp, {}) or {}
title = str(rec.get("title") or rec.get("name") or wf_id)
if confirm != title: return need_confirm
...
os.remove(cp) # <-- traversal delete
So wf_id="../../<path>" walks out of WS/workflows/ and os.remove deletes any
existing .json the process can reach. The typed-name confirm gate does NOT save
it: title falls back to wf_id whenever the TARGET file has no "title"/"name" key
(keys.local.json, plan pins, team/manifest.json, team/manifest_highwater.json,
seen_envelopes.json, receipts, allowlists.json, workspace.json — none carry a
"title"), so confirm == wf_id passes. This is an authenticated
arbitrary-.json-DELETE on the host: wiping keys.local.json breaks integrations;
deleting team/manifest_highwater.json lowers the monotonic replay floor so a
superseded manifest (with a since-removed approver) can be re-adopted; deleting
plan pins or approval state removes governance records.
Suggested fix:
Apply the same id guard the sibling endpoints use, at the top of _handle_delete,
before the id is ever joined into a path:
wf_id = str((body or {}).get("id", "")).strip()
if not re.match(r"^[A-Za-z0-9_-]{1,80}$", wf_id):
return handler._send(200, {"ok": False, "error": "invalid workflow id"})
Defence in depth: after building cp, confirm it stays inside the workflows dir —
wdir = os.path.realpath(os.path.join(WS, "workflows"))
if os.path.realpath(cp) != os.path.join(wdir, wf_id + ".json"):
return handler._send(200, {"ok": False, "error": "invalid workflow id"})
Separately, the typed-name confirm gate should compare against a value that does
NOT fall back to the caller-supplied id (require an explicit stored title, or
refuse deletion of a rail that has none), so confirm can never be trivially
satisfied by echoing the id.
Note: the audit_log at line 321 and the receipt DO run in the live server (now_iso
is a _LATE name warmed into module globals at startup), so the delete is NOT
silent — but a receipt/audit line recording a "RAIL_DELETED" for a "../"-path id
is itself the tell that the id was never validated.