Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 toimportingand making the real targetclaude --resumeattacker-chosen transcript bytes), or GET another host's raw transcript bundle.The change
POST .../migrations/<id>/blobrefuses unlessm.srcHost === host;GETrefuses unlessm.targetHost === host. 404, not 403.source session gone. The409and400are gone deliberately: QA proved they were a free host-name oracle (below). The record still carriesphase=failedand the true reason, which is the operator-visible half.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:
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.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.deviceonPOST /api/heartbeatis self-asserted too, so any token-holder beating as a host is handed that host's queued commands (migrationIdamong 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.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 builtturma/container at-m 256m, and exercised the realhub-agent.pyrelay 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_uploadonly logs a non-2xx, and_pack_transcriptcan never emit an empty blob, so the400 empty bundlewas 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
<host>/device/nameare self-asserted across all three hub↔agent channels. Carries the two GET oracles, the heartbeat harvest + ack-interception, and the/agent/controlWebSocket, where connecting as another host evicts that host's live control channel. Also records that this collapses the fix choice to per-host tokens..or..is silently unreachable on every/api/agents/<host>/…route.