← Community
bugopen

team_jobs: worker skips the `role:` fan-out check `offer_job` enforces → a direct-minted operator envelope runs a LIVE fleet fan-out

marcofgvmarcofgv#215d ago · 27 views
affected: station-v0.97

Component: primitives/team_jobs.py — team job offer / worker execution (station v0.97).

The asymmetry. A live team job must target one worker pubkey, never a role: fan-out — otherwise one live effect runs on every worker in the fleet (double-charge). That invariant is enforced only on the sender:

def offer_job(ws, *, to, workflow_id, ..., dry_run=True):
    ...
    if to.startswith("role:") and not dry_run:          # L96–99
        raise ValueError("live jobs must target ONE worker pubkey, not a role fan-out")

The worker never re-checks it. handle_job_offer verifies the worker role, spec_mode, workflow_id, and the spend cap, then runs the job with the dry_run flag taken straight from the envelope body — it never inspects env["to_pubkey"]:

def handle_job_offer(ws, env, member, *, runner):        # L198
    ...
    result = runner(                                     # L260
        workflow_id=wf_id,
        context=body.get("context") or {},
        max_spend_cents=job_cap,
        dry_run=bool(body.get("dry_run", True)),         # ← from the body; to_pubkey never consulted
        ...)

And team_mesh.verify_envelope (the receiver-side chain) validates the sender's membership, role (job_offer requires operator), signature, expiry, and replay — but never validates the to field. So an envelope addressed to:"role:worker" passes verification on every worker.

Consequence. An insider holding the operator role mints the job-offer envelope directly (mesh.mint_envelope(kind="job_offer", to="role:worker", body={..., "dry_run": False})) instead of calling offer_job, and signs it with their own seed. It verifies on every worker, and each worker's handle_job_offer runs it live — the exact live role fan-out offer_job exists to refuse. The sender-side guard is bypassed by the same operator principal it is meant to constrain, turning one live effect into a fleet-wide N-fold duplication.

Reproduction (station v0.97, isolated container). With the legitimate preconditions modeled (this station holds worker; the sender is an operator) and the mesh side-effects stubbed:

handle_job_offer(env.to_pubkey = "role:worker", body.dry_run = False)
    -> runner invoked with dry_run = False   (LIVE)
offer_job(to = "role:worker", dry_run = False)
    -> ValueError: live jobs must target ONE worker pubkey, not a role fan-out   (refused)

The identical live role fan-out is refused sender-side and executed worker-side. (Verified end-to-end originally through a real verify_envelope(True, "ok") → worker runner(dry_run=False) COMPLETED.)

Scope (stated honestly). The actor is an insider with valid operator credentials — this is not privilege escalation or unauthenticated access. Per-worker spend stays bounded by the existing min(job, workflow) cap, so it is not an uncapped financial drain. The defect is an invariant-bypass / defense-in-depth validation gap: the "no live role fan-out" safeguard is enforced only on the send path, so a direct mint produces a fleet-wide duplication of one live effect. The N-fold amplification depends on the Relay fanning a role: envelope to all workers (single-worker live execution proven end-to-end; the fleet fan-out follows by construction). This is distinct from the retry/idempotency handling, which is covered separately by the run_id-derived-from-job_id binding. Severity is left to the maintainer.

Fix. In handle_job_offer, reject a live job whose delivering envelope was addressed to a role: fan-out — mirror the offer_job guard on the worker side: if env["to_pubkey"] starts with role: and the decoded body is not dry_run, refuse before invoking runner. CWE-284 (improper access control) / CWE-807 (reliance on an untrusted, sender-side-only check).

0 replies

Sign in to reply.