Skip to content

XERK-266: scope the migration blob relay to its own two hosts - #436

Merged
xerhab merged 7 commits into
mainfrom
XERK-266
Aug 12, 2026
Merged

XERK-266: scope the migration blob relay to its own two hosts#436
xerhab merged 7 commits into
mainfrom
XERK-266

Conversation

@xerhab

@xerhab xerhab commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes XERK-266.

The defect

Both migration blob routes decoded the <host> path segment and never used it, looking the migration up by id alone — unlike the file-upload relay directly below them, which was written the other way. Every agent shares one token, so agent-auth was the whole check: any host could POST a bundle into a move it is not the source of (advancing it to importing and making the real target claude --resume attacker-chosen transcript bytes), or GET another host's raw transcript bundle.

The change

  • POST .../migrations/<id>/blob refuses unless m.srcHost === host; GET refuses unless m.targetHost === host. 404, not 403.
  • Every refusal on the POST is now the same 404 — unknown id, wrong host, wrong phase, empty body, and the late source session gone. The 409 and 400 are gone deliberately: QA proved they were a free host-name oracle (below). The record still carries phase=failed and the true reason, which is the operator-visible half.
  • Comment corrections on both routes and on the uploads relay, which carried the same over-claim.

What this does NOT do — read before reviewing the comments

The <host> segment is self-asserted. All agents share one token, so a caller that names the victim's host still passes both guards. QA reproduced both attacks against the patched build. This change is defense in depth — a mis-addressed call now fails — not an authorization boundary. Binding the segment to the credential is XERK-268, filed with the full repro, and the comments say so rather than implying a closed door.

Two leaks remain on the POST and are documented in place: response timing (the guard runs before the body read, so a caller that passes it blocks — 15 probes and 960 bytes to find a source, mutating nothing), and the 413. Neither is closeable without reading an unbounded body from an unverified caller. Both GETs remain walkable because the successful read is the disclosure.

Verification

Adversarial QA agent, six passes, three FAILs and every one taken:

  1. FAIL — the guards don't achieve the stated property (host segment self-asserted). Filed as XERK-268; corrected the comments that claimed otherwise.
  2. FAIL — the corrected comments still overstated: a zero-length POST body passed the guard and stopped at the 400 empty bundle, so the real source answered 400 where every other host answered 404. Source recovered in 15 silent probes, target in 120 off the GET. Led to the uniform-404 change here.
  3. FAIL — still an oracle via response timing; comment reworded to name it, with its measured cost.
  4. FAIL — a mechanical inventory of the route's replies found a fourth response only the real source could reach, 409 source session gone, after the body read. Closed it (uniform 404) rather than describing it; also corrected the 413's cost, which overstated an attacker's price ~64x.
  5. FAIL — the migration id isn't a precondition at all: device on POST /api/heartbeat is self-asserted too, so any token-holder beating as a host is handed that host's queued commands (migrationId among them), and acking them removes them from the real agent. Pre-existing, routed to XERK-268 — where it also rules out that ticket's per-migration-secret option, since the secret would ride the very command a prober can harvest.
  6. PASS — raw-socket enumeration of all three routes: every refusal byte-identical including headers, only the 200s carry Content-Length, terminal states indistinguishable even under a stall probe, no SSE correlation for an agent-authed prober. Two wording nits, both taken in the final commit.

Run: node --test turma/tests/*.test.js agent/tests/*.test.js .github/scripts/tests/*.test.js → 1073 pass / 0 fail; python3 -m unittest discover -s agent/tests → 1288 pass. Mutation battery 12/12 caught, including dropping only the phase clause (which now shares a line with the host guard) and restoring each removed status. QA drove a native rig and the built turma/ container at -m 256m, and exercised the real hub-agent.py relay halves (_migration_upload/_migration_download) against the patched hub, including a device name needing percent-encoding.

Not verified: a real two-host migration with live agents (tmux, claude --resume, a real gzipped transcript tar) — one box, no second agent host, no Claude login. Both agent halves were driven as real code against the hub, but the unpack/resume tail was not.

No regression from collapsing the refusals: nothing in turma/ agent/ android/ glasses/ veiller/ parsed the removed statuses, _migration_upload only logs a non-2xx, and _pack_transcript can never emit an empty blob, so the 400 empty bundle was unreachable for a real source.

Tests

turma/tests/server.test.js:

  • migrate: the blob relay is scoped to the migration's own two hosts — a third agent-authed host 404s on both halves; the near-miss POST runs while the migration still awaits its bundle, the one state where the host compare is the only thing that can answer it.
  • migrate: every POST refusal is the same 404, so the responses name no host — the four refusals are byte-identical, and the vanished-source case still records the true reason.

Findings routed, not dropped

  • XERK-268 (filed) — <host>/device/name are self-asserted across all three hub↔agent channels. Carries the two GET oracles, the heartbeat harvest + ack-interception, and the /agent/control WebSocket, where connecting as another host evicts that host's live control channel. Also records that this collapses the fix choice to per-host tokens.
  • XERK-269 (filed) — pre-existing: a host whose device name is . or .. is silently unreachable on every /api/agents/<host>/… route.

xerhab added 7 commits August 12, 2026 13:03
Both migration blob routes decoded the <host> path segment and never used
it, looking the migration up by id alone. Every agent in the fleet shares
one token, so agent-auth was the whole check: any host could GET another
host's raw transcript bundle, or POST a bundle into a move it is not the
source of and have the real target claude --resume those bytes.

Scope each route to its own half of the migration, mirroring the file
upload relay directly below them: POST against m.srcHost, GET against
m.targetHost, 404 rather than 403 so the relay does not confirm the id
exists to a host with no business asking.
QA reproduced both attacks against the guards: because every agent shares
one token, the <host> segment is the caller's to pick, so a non-participant
that names the real source still injects and one that names the real target
still downloads. The guards are defense in depth — an attacker now needs the
two host names on top of the migration id — not the identity check the
comments claimed. Corrected them, including the uploads relay's, which was
mirrored from and has the same property. Binding the segment to the
credential is XERK-268.

Also pin the compare's exactness: a case-variant host name survived a
mutation of the guard with the suite green.
QA falsified the claim I put in the last commit's comments. The routes are
a host-name oracle: a zero-length POST body passes the host guard and stops
at the empty-body 400, so the real source answers 400 where every other host
answers 404 — the source was recovered in 15 probes, silently and without
mutating anything, and the target in 120 from the GET's 200-vs-404. An
attacker therefore needs neither host name, only the migration id.

So the POST now answers ONE 404 for every refusal — unknown id, wrong host,
wrong phase, empty body — which closes the free enumeration on that route
and leaves only the 413, which costs an oversized upload per probe and fails
the migration loudly. The 409 and 400 are gone deliberately; the real source
acts on neither.

The GET cannot be de-oracled that way (the success IS the disclosure), so
its comment says so and points at XERK-268 instead of implying a barrier it
doesn't have. Same correction on the uploads relay, which is now explicitly
in XERK-268's scope. Also dropped the claim that the migration id reaches
only the two agents — it rides the user-authed fleet payload and SSE too.

Tests: the near-miss POST moved to a migration still awaiting its bundle,
where the host compare is the only thing that can answer it (in the old
placement it sat behind the phase check and passed on a lenient build), plus
a new case pinning the uniform refusals. 9/9 mutants caught, including the
two that escaped before.
QA falsified the previous comment again: the guard runs before the body
read, so an accepted caller blocks while a rejected one answers at once.
Holding the socket open (never completing, never destroying it) makes that a
free, silent, non-mutating oracle — 15 probes and 960 bytes to find a
migration's source, phase untouched, nothing logged. The uniform 404s close
the status-code channel only.

Comment now says the oracle is narrowed, not shut, names both residual leaks
and why neither can be equalized without buffering an unbounded body from an
unverified caller, and drops the claim that an injector must commit the
injection against each candidate. XERK-268 carries the same correction.
QA's mechanical inventory of the route's responses found one my reading
missed: 409 source session gone, after the body read. A non-source is 404'd
by the guard long before it, so that status named the source to anyone
holding the migration id — the same defect as the 409 and 400 already
collapsed, and the only one of the residual leaks that costs nothing to
close. The reply is now the uniform 404; the RECORD still carries
phase=failed and the real reason, which is the half the operator reads.
Nothing consumed the old status.

Also corrected the 413's cost, which overstated an attacker's price by ~64x
(the guard cuts a wrong host off after ~1 MiB; only the hit pays the full
cap), renamed the oracle test to claim responses rather than the route, and
left a note to enumerate this route's replies rather than eyeball the guard,
since that is how the branch was missed twice.
QA's fifth pass found the id is harvestable: device on /api/heartbeat is
self-asserted exactly like <host> here, so any token-holder beating as a
host is handed that host's queued commands, migrationId among them (and
acking them takes them from the real agent). Pre-existing and outside this
change, but it refutes what this comment said about the id's reachability,
so the comment now warns against reasoning from it and points at XERK-268.

Also made the timing wording exact — a caller that PASSES the guard blocks,
which is what makes a refusal added after the body read invisible to the
uniform status — and rewrote the block, which had sprawled across four
patches.

XERK-268 carries the heartbeat channel, and with it the decision: the
per-migration-secret option is defeated by the harvest (the secret would
ride the very command a prober can collect), leaving per-host tokens.
The 413 is a refusal too, so 'every refusal answers the same 404' now names
it as the exception rather than being contradicted eight lines later; and
'reading' an unbounded body is the accurate verb, since the timing channel
could be equalized by draining without buffering.
@xerhab
xerhab merged commit a94bbe9 into main Aug 12, 2026
8 of 9 checks passed
@xerhab
xerhab deleted the XERK-266 branch August 12, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant