Conversation
Every agent-authed surface names the host it acts as — the `<host>` path segment, the heartbeat's `device`, the tunnel's `?name=` — and under one fleet-shared token none of those were backed by anything. Scoping a relay to `m.srcHost`/`m.targetHost`/`u.host` therefore refused a caller naming ITSELF and passed one naming the victim. `device` is self-asserted the same way, and a heartbeat reply hands back that host's queued commands, so the shared token ALONE was enough: beat as the source, collect the exportSession command carrying the migration id, then relay a forged bundle and choose the cwd the real target is told to resume at. That also rules out the per-relay one-time secret this ticket weighed as the other option — it would ride on exactly the commands the impersonated beat hands out. So bind the credential to the host instead. Each agent runs on its own token, HMAC(TURMA_AGENT_TOKEN, <device>); the hub keeps only the master and re-derives what it expects for whatever host a request names, so no host list and no restart to add one. That fixes every agent-authed route at once, including ones added later, and the two migration blob guards this ticket was filed against land here too — with the binding they finally mean what they say. The master still authenticates as `legacy`, so a fleet mid-rollover keeps beating and every existing deployment is unaffected; TURMA_AGENT_STRICT retires it, and the hub warns at boot until it is set. ttyd is proxied with whichever token the host actually authenticated with, so a half-rolled fleet keeps its terminals. No agent-side code change — only the value of TURMA_TOKEN. `node turma/server.js --agent-token <device>` prints a host's token.
The gate decodes the segment to check the credential against it, so a bad percent-escape threw there instead of in the route — turning an anonymous caller's 401 into a 400 that ran before any auth did. Match it raw when it does not decode; the route still 400s for a caller that got past auth.
D1 (HIGH, introduced here): the heartbeat's pre-body gate accepted any
`Bearer <anything>`, because a bare HMAC digest cannot be recognised without
already knowing the host — so an anonymous caller reached a 32 MiB readBody plus
JSON.parse and the normalizers. 8 concurrent 31 MiB posts killed a 256 MiB hub
container 3/3 with a V8 heap OOM, taking the control plane for the whole fleet;
origin/main answers 401 before the read.
Fixed at the root by making the token NAME its host:
`<base64url(device)>.<HMAC(master, device)>`. The hub reads the name off the
token and re-derives to verify it, so a caller is identified from the credential
alone — before a body, a path, or a registry lookup. `agentPresented` refuses an
unknown bearer again, and the two riders on the same root cause go with it: an
unauthenticated caller no longer gets JSON.parse internals back, and no longer
gets an attacker-chosen `device` written into the hub's log pre-binding.
D2 (HIGH, pre-existing on the line this diff rewrote): `pendingChannels` was a
plain object, so `/agent/data?ch=__proto__` read back Object.prototype — truthy,
past the "is there a pending channel" check, dead on the next property access,
out of an async upgrade handler with no unhandledRejection hook. Both tunnel
maps are null-prototype now.
D3: hub-agent.py discarded the hub's refusal body, so a rollover gone wrong read
as "HTTP Error 403: Forbidden" and a host that silently vanished. The heartbeat
and the updating announce now log through _http_error_detail, which learns the
hub's {error} shape alongside the two trackers'.
D4: a valid token for the WRONG host answered a bare 401. Its likeliest cause is
a host rename — the name is inside the token — so it answers 403 naming both
hosts. It leaks nothing: the token names its own host on its face.
D5: the three mutants that escaped now have tests — the migration GET's
targetHost scope (which, unguarded, hands any host another's raw transcript
knowing only an id that rides /api/agents), a wrong host answering a pending
data channel, and the __proto__ crash above.
D6: hostAgentToken refuses a non-string host outright; String() would have
coerced an array or a toString()-carrying object into a REAL credential.
D9: agentAuthorized was dead in the request path and read like a gate. Removed;
agentPresented is the one coarse check.
D7/D8 stand as designed and are called out in the PR.
QA's delta pass cleared the FAIL and found one real gap: `agentPresented`
accepted the master unconditionally, so on a hub where the master was supposedly
retired a master-holder still reached the 32 MiB readBody + parse before the
403. 8 and 24 concurrent 31 MiB posts killed a strict 256 MiB container with a
V8 heap OOM. Not a regression — origin/main dies to the same attack, since there
the master was the only agent credential — but "the master then stops being
accepted" was this change's own claim, and a leaked master is exactly what
strict mode exists to contain.
Strict now refuses the master at that gate, and refuses it with the rollover
message rather than a bare 401: the host is still unread behind the gate, so
`agentPresentedRefusal` says the same thing without naming one. Verified on the
container — 24x403, hub alive, fleet reachable, and the body provably unparsed
(invalid JSON gets the 403, not a parse error).
Also from that pass:
- N5: a host name that does not survive the UTF-8 round trip now gets no token.
A lone surrogate encodes to the replacement character, so two different names
derived one credential. Unreachable in practice; the derivation should be
injective regardless.
- N6: `_http_error_detail` checks the hub's {error} AFTER both tracker shapes,
so teaching it that key cannot change what an existing Jira/ADO failure says.
- N3/N4: the tunnel maps and `tokenHost` are exported. The __proto__ guard is
now asserted on the maps directly — reaching it through a socket meant the
regression was detected by the hub DYING mid-run, which reads as a CI timeout
rather than a failing test.
- N2: tests for the canonicalization invariant (only the canonical base64url
spelling of a host authenticates) and for the malformed-segment fallback.
QA's M4 escape is not one: moving `tokenBound` before the `...payload` spread
fails `XERK-268: ttyd is proxied with the token that host actually runs`.
Verified by applying the mutant to a copy of the tree.
XERK-266 landed on main while this was in QA, along with XERK-263's spool-to-disk relay. Both sides had grown the same two migration blob routes. The route bodies are main's entirely: its uniform-404 refusals and disk spooling are strictly ahead of what this branch carried, and this branch's contribution there was only the `m.srcHost`/`m.targetHost` scope main already has. What is kept is the binding at the gate, which is orthogonal to all of it. The comments are rewritten rather than taken from either side, because XERK-268 changes what main's now say. Where main documents the scoping as "defense in depth, NOT an identity check" and names the two oracles its uniform 404s could not close — the reply timing and the 413 — both needed an unverified caller, and there is no longer one. The uniform 404s stay: they are what still holds if a future route reaches that code with a weaker credential. Test suites are additive on both sides, so both are kept. This branch's "scoped to the migration's own two hosts" is renamed to "the blob relay's host scope holds for a proved caller" — main's XERK-266 test owns the original title, and the two check different things. One real conflict of behavior, fixed rather than papered over: XERK-263 added MIGRATE_INFLIGHT_MAX (4), counted fleet-wide over exporting/importing moves, and this branch's tests left three in flight — so main's later tests got a 503 and failed somewhere unrelated to what they test. Added settleMigration() and called it from the three tests that start a move. node 1127/1127, python 1288 OK.
Published today; it fails the agent-image scan on every open PR, not just this one (XERK-265 is red on the same finding). Exactly one finding, only in /usr/local/bin/terraform, where x/net v0.55.0 is compiled in. terraform 1.15.8 is still the newest stable — 1.16.0 is at rc1 — so TERRAFORM_VERSION has nothing to bump to short of putting a pre-release in a production image. Same shape and same reasoning as the three terraform entries already in this file, with an expiry to force the revisit.
Only conflict is .trivyignore: PR #441 landed the same CVE-2026-46600 triage while this was in CI. Took main's entry wholesale and dropped this branch's — same finding, same conclusion, and the shared file should carry one copy in the voice that got there first. Nothing of this branch's reasoning is lost; main's wording is if anything more specific about the release index it checked.
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.
Closes XERK-268.
The defect, and how it turned out to be bigger than the ticket
<host>in/api/agents/<host>/…is a value the caller types. With one fleet-sharedTURMA_AGENT_TOKEN, nothing backed it — so scoping a relay tom.srcHost/m.targetHost/u.hostrefused a caller naming itself and passed one naming the victim.Verifying that, I found the ticket's severity rationale understated.
deviceon/api/heartbeatis self-asserted the same way, and the reply hands back that host's queued commands — so the shared token alone was enough, with no leaked id:{"device":"hsA"}→ gets back{"type":"exportSession","migrationId":"…"}.worktreePath:"…/PWNED", then POSTs forged bytes to/api/agents/hsA/migrations/<id>/blob→{"ok":true}.importSession … cwd:"…/PWNED"— attacker-chosen bytes and cwd.That also rules out the per-relay one-time secret the ticket weighed as the other option: it would ride on exactly the commands an impersonated heartbeat hands out. Only binding the credential to a host closes it. Chosen with the ticket's author.
The fix
Each agent runs on its own token,
<base64url(device)>.<HMAC-SHA256(master, device)>. The hub keeps only the master and re-derives it for whatever host a request names, so there is no host list and no restart to add one.The token names its host on purpose. An HMAC can't be inverted, so a bare digest could only be checked once the host was known — and
/api/heartbeat, whose host is buried in a 32 MiB body, would have had to admit any bearer before reading it.tokenHost()reads the name half and re-derives, comparing the whole token, so editing the name breaks the HMAC.agentBearerKind(req, host)→proved/operator/legacy/nullis the one resolver. Bound at: the three blob relays, archive ingest, the updating signal, the heartbeat'sdevice,/agent/control's?name=, and/agent/data(which now carries the host its channel was opened for). This fixes every agent-authed route at once, including ones added later — and the two XERK-266 guards land here too, since that branch is unmerged; with the binding they finally mean what they claim.Compatibility — nothing breaks on merge
The master still authenticates as
legacy, so upgrading the hub alone changes nothing and every agent keeps working.TURMA_AGENT_STRICT=1retires it; the hub warns at boot until it is set.ttydAuth(host)sends whichever token that host actually authenticated with, so a half-rolled fleet keeps its terminals. No agent-side behavior change — only the value ofTURMA_TOKEN.node turma/server.js --agent-token <device>prints a host's token.The DockerOps rollover is XERK-275, deliberately not here: an agent presenting a derived token to a pre-XERK-268 hub gets a flat 401, so the hub must ship first.
Verification
Adversarial QA (
~/.claude/agents/qa.md) ran three passes; the first two were FAIL and PARTIAL and both were right.FAIL — an unauthenticated remote DoS I introduced. Binding the heartbeat forced its gate to a weaker check that accepted any
Bearer <anything>, so an anonymous caller reached a 32 MiBreadBody+JSON.parse. 8 concurrent 31 MiB posts killed a 256 MiB container 3/3 with a V8 heap OOM — the control plane for the whole fleet. Fixed at the root by the self-identifying token above. Re-measured: 401 in 3 ms, RSS +256 KiB vs +143 MiB, container survives 8 and 24 concurrent. Two riders died with it (aJSON.parseinternals leak, and an attacker-chosendevicereaching the log pre-binding).PARTIAL —
TURMA_AGENT_STRICTdid not bite at that gate. A master-holder still reached the read before the 403, so a leaked master still OOM'd a fleet whose whole point was that the master had been retired. Not a regression (origin/maindies to the same attack) but it was this change's own claim. Now refused at the gate, with the rollover message rather than a bare 401. QA re-verified by memory, not status code: refused master +3 MiB RSS vs +137 MiB for an accepted beat — a 45× gap proving the body is unread.PASS. All six claims confirmed with running evidence; 23/24 mutants caught, the survivor empirically proven equivalent. What QA could not break: 20 forged-token shapes and 40 non-canonical encodings all rejected; no timing oracle (spread 1.70×, non-monotonic, full match fastest); no host-enumeration oracle — a nonexistent host name gives an identical refusal; gate and handlers cannot disagree on the decoded segment. Full cross-host matrix over 6 surfaces × 2 rigs, 26-case WebSocket matrix, migration/attachment/archive/terminal E2E, and the real
hub-agent.pydriven both ways.Also fixed from QA: the
/agent/data?ch=__proto__hub kill (pre-existing, on a line this diff rewrote — both tunnel maps are null-prototype now); the agent discarding the hub's refusal body, so a bad rollover no longer reads as a bareHTTP Error 403; a wrong-host token answering 403 naming both hosts, since its likeliest cause is a rename (the name is inside the token);hostAgentTokenrefusing non-string and non-round-tripping names, whichString()would have coerced into a real credential; and tests for the three mutants that escaped — including the migration GET'stargetHostscope, which unguarded hands any host another's raw transcript.I disputed one finding with evidence and QA retracted it: the
tokenBound-ordering mutant is caught (their mutant left the original assignment in place, making it a no-op).Suites: node 1099/1099, python 1288 OK.
Known limitation, not introduced here
An authenticated agent can still OOM the hub with 8 × 31 MiB beats. Inherent to the design — the host is in the body, so the body must be read to bind it — and present on
origin/mainvia its equivalent credential. Filed as XERK-288 rather than scope-crept into this PR; the fix is a decision about what rides the heartbeat.Not verified
Android / glasses / veiller were not booted: no wire change (
tokenBoundis stripped from/api/agentsand SSE, no field added or retyped), so noPARITY.mdentry is owed. Also unexercised: a real two-agent migration with live tmux/claude (the auth-relevant half is covered), the Sessions Move UI, theagent-hostimage, and a DockerOps rehearsal.Merge with main, and one thing carried that isn't mine
XERK-266 and XERK-263 landed while this was in QA, and both had grown the same two migration blob routes. The route bodies are main's entirely — its uniform-404 refusals and disk spooling are ahead of what this branch carried, and this branch's contribution there was only the
srcHost/targetHostscope main already has. What is kept is the binding at the gate, which is orthogonal.The comments are rewritten rather than taken from either side, because this change alters what main's now say: where main documents the scoping as "defense in depth, NOT an identity check" and names the two oracles its uniform 404s could not close — the reply timing and the 413 — both needed an unverified caller, and there no longer is one. The uniform 404s stay: they are what still holds if a future route reaches that code with a weaker credential.
Test suites were additive on both sides, so both are kept; this branch's duplicate title was renamed. One real behavioral conflict, fixed rather than papered over: XERK-263's
MIGRATE_INFLIGHT_MAX(4) counts in-flight moves fleet-wide, and this branch's tests left three of them, so main's later tests got a 503 and failed somewhere unrelated to what they test. AddedsettleMigration()to the three tests that start a move..trivyignorecarries one entry that has nothing to do with this change. CVE-2026-46600 (golang.org/x/netin the bundled terraform) was published today and fails the agent-image scan on every open PR — XERK-265 is red on it too. terraform 1.15.8 is still the newest stable (1.16.0 is at rc1), soTERRAFORM_VERSIONhas nothing to bump to short of shipping a pre-release into a production image. Triaged in the same shape and with the same reasoning as the three terraform entries already in that file, with an expiry to force the revisit. Called out here because it is unrelated to the rest of the diff — revert that commit alone if you would rather handle it separately.