← Community
bugfixed

A non-numeric sequence_number from the untrusted Relay crashes the poll cursor sort and permanently wedges all inbound mesh processing

ShwetaShweta#118d ago · 24 views
affected: station-v1.0.0fixed in: station-v1.3.0

The Relay poll loop dispatches each pending event under a try/except but
advances its cursor with an unguarded int() over a Relay-controlled field, so
one event carrying a non-numeric sequence_number raises before the loop can
save progress — and because the cursor never advances, the same poisoned batch
is re-fetched and re-crashes on every subsequent poll.

relay_client.poll_and_ack wraps the per-event handler:

for evt in events:
try:
if on_event(evt):
to_ack.append(evt["id"])
except Exception:
pass # not acked; must re-deliver

but the cursor-advance step is not wrapped:

for evt in sorted(events, key=lambda e: int(e.get("sequence_number") or 0)):
seq = int(evt.get("sequence_number") or 0)
if evt.get("id") in confirmed:
new_since = max(new_since, seq)
else:
break

int(evt.get("sequence_number") or 0) runs inside the sort key, over every
event in the batch, outside any try. A sequence_number of "x" (or any
non-numeric string) raises ValueError there, so poll_and_ack raises after the
dispatch loop and before _save_since — the cursor since is not updated.

sequence_number is a Relay-transport field. It is not part of a team_mesh
envelope and is covered by no signature: team_mesh.canonical_bytes signs the
envelope carried in evt["body"], never the transport wrapper the Relay adds
around it. The trust model states plainly what the Relay is allowed to be — from
team_mesh's header: "The Relay is a dumb, untrusted post office: it can drop or
delay envelopes ... but every receiver runs the full verification chain locally
against its OWN adopted manifest, so a compromised Relay can never forge a team
fact." Forging is prevented. Denial is not: a compromised or malicious Relay
attaches sequence_number: "x" to ANY event it serves — including a perfectly
valid, correctly-signed envelope from a real teammate — and:

· poll_and_ack raises every cycle at the sort;
· AutoPollThread.run() catches it ("network / relay down — back off to idle")
and keeps the station up, so nothing surfaces as a crash;
· the poisoned event is never acked-and-confirmed, so since never advances,
so the next /relay/pending returns the same batch and it raises again.

Every inbound mesh message halts for as long as the Relay serves that event:
approval responses that unblock a waiting workflow (team_approval), capability-
use results (team_share), team job results (team_jobs), and relayed webhooks
(routes/relay). A legitimate event sorted alongside or behind the poison is not
delayed but stuck until an operator notices the channel has gone silent and
intervenes out of band. One unsigned field set by the transport denies the
whole channel — the capability the design says the transport must not have.

Reproduction steps:

  1. Extract the station-v1.0.0 tarball to a clean directory and put workbench/

on sys.path.

  1. Stub relay_client._post_json to play the Relay: return {"events": [...]} for

"/relay/pending" and {"acked": N} for "/relay/ack". Stub _poll_signature to
return any string so no real keypair is needed.

  1. Poll once with two events whose sequence_number is 1 and 2 and an on_event

that returns True — confirm the cursor (relay_client._load_since) advances
to 2.

  1. Poll with a single event whose sequence_number is the string "x" (its body

may be a genuine signed envelope). Repeat the poll two more times.

  1. Poll with a batch of two events: one with sequence_number "x" and one valid

event with sequence_number 3.

Expected: a malformed transport field on one event is rejected as that event's
problem — it is skipped or quarantined — and the rest of the batch, and every
later poll, keep flowing.

Actual:
step 3 cursor since = 2 (normal flow)
step 4 every poll raises ValueError("invalid literal for int() with base
10: 'x'"); cursor stays at 2; the event stays pending and re-poisons
the next poll
step 5 the poll raises before the valid seq-3 event is counted; cursor
stays < 3; AutoPollThread would swallow the exception and idle

Root cause: input from the untrusted transport is coerced with a bare int() in
the one code path that is not exception-guarded, and the field is a stable part
of every fetched batch, so the failure is not transient — it recurs until the
input goes away, which the attacker controls.

Suggested fix: coerce defensively where the batch is ordered — a helper that
maps a non-integer sequence_number to a sentinel (e.g. treat unparseable as
0 or drop the event with an inbox denial line) instead of int(... or 0)
raising. More broadly, validate the transport envelope shape (id present,
sequence_number integer, kind a string) at the top of the per-event loop and
quarantine anything malformed as a denial the same way team_mesh.receive
quarantines a bad body — a single event must never be able to stop the batch or
the cursor. The per-event try/except already embodies this intent; the
cursor-advance sort is the one place it was not applied.

1 reply

Fixed in station-v1.3.0. Duplicate of the non-numeric sequence_number wedge — fixed by _seq() coercion.

Sign in to reply.