Skip to content

fix(mesh): attribute command results, stop broadcasting device commands, bind transfer tokens, bound the registry - #655

Merged
claudiusthebot merged 1 commit into
mainfrom
fix/mesh-hardening
Jul 25, 2026
Merged

fix(mesh): attribute command results, stop broadcasting device commands, bind transfer tokens, bound the registry#655
claudiusthebot merged 1 commit into
mainfrom
fix/mesh-hardening

Conversation

@claudiusthebot

Copy link
Copy Markdown
Collaborator

Follow-up to #648, which covered the remotely-reachable bridge issue. These four are the mesh-side findings from the same audit β€” reachable by a connected/compromised companion device rather than by an arbitrary website.

1. completeCommand device-spoofing bypass

service.ts: if (from && from !== pending.deviceId) return false; β€” the empty-string fallback (kept "for older app builds") meant a reply that omitted deviceId skipped the ownership check entirely. Combined with #2, any connected device could race and feed the model fabricated exec stdout or a fake install success.

result with NO deviceId    -> accepted (resolved with spoofed stdout)
result with WRONG deviceId -> rejected   <- the check only worked when the field was present

Now rejected outright rather than "accept when exactly one command is pending" β€” the one-pending variant still can't attribute the reply, it only narrows the window to whenever the mesh is idle, while looking like a check. Back-compat cost is nil: deviceId has always been in the command-result wire contract (protocol/fixtures/mesh_v1.json, asserted by daemon/node/companion conformance tests) and both shipped clients already send it.

2. Device commands were broadcast to every SSE client

Comments claimed "each client filters by its own device id" β€” client-side courtesy, not enforcement. Every connected client received one-time transfer tokens, exec command lines, remote paths, and on the chunked fallback the entire base64 file body. A device_push_file to phone A handed the file to phone B.

Clients now name themselves with GET /events?deviceId=…; sendToDevice() delivers device_command to the claiming client(s) only. locate still fans out (no secret in the frame, and pre-command builds depend on it). A client that claims nothing receives a command only when nobody claimed its target, so updated devices' traffic is never visible to legacy clients and the fallback shrinks as the fleet updates.

3. Transfer tokens weren't bound to their device

transfers.ts take() validated token/direction/expiry but never that the peer was the device the token was minted for β€” so a second device could consume another's push token (β†’ arbitrary daemon-host file) or satisfy a pull token (β†’ attacker bytes written to the daemon's destination). Now checked, and checked before the single-use latch so a wrong claim can't burn the real device's token.

Deliberate concession: an absent claim is still served. Making it mandatory would break the transfer that ships the client build able to make it β€” update_device/update_node push the new binary over download_file, so a daemon upgraded ahead of its fleet would refuse the very transfer that updates the fleet. Dropping the fromDeviceId && guard later makes it mandatory in one line.

4. Unbounded registry growth

sanitizeDevice bounded only arch and capabilities; id/name/appVersion were unbounded to the 256 KB body cap, and storeLocation accepted any deviceId β€” registered or not β€” rewriting all three sidecar files in full each time.

one registration with 100000-char fields -> mesh-devices.json = 300141 bytes
50 locations for never-registered ids    -> all 50 persisted

Caps: id 128 (refused if longer β€” truncating an identity key would merge devices), name 128 / appVersion 64 (clamped, display-only), device map capped at 128 with least-recently-seen eviction, orphan locations capped at 16.

Verification

tsc --noEmit, oxlint, prettier --check clean. Full non-integration suite passes (4067). New tests cover each fix through real code paths, including two live SSE connections proving a command reaches only the claiming client while broadcast still reaches everyone, and an over-HTTP check that a rejected transfer claim doesn't burn the token.

Please double-check

  • The Go and Dart client changes are unbuilt. Neither toolchain is installed in my environment. The edits are small and inspect clean (n.DeviceID exists, net/url already imported; eventsUrl() output is unchanged when no id is passed, so bridge_models_test.dart should still pass) β€” but they want a real build.
  • Claim spoofing remains possible: any client holding the bridge bearer token can claim another device's id. There's no per-device credential in this protocol, so that's the same trust boundary as before β€” this removes passive leakage, not a privileged attacker. The single shared bridge token across all mesh devices is the underlying design issue and is not addressed here.
  • On a brand-new companion install the very first stream may connect unclaimed, with the claim landing on the next reconnect. Falls back safely rather than dropping commands.

Most of this work was done by a parallel agent in an isolated worktree; I rebased it, re-verified it in a working environment (its own run had 17 environmental failures from an empty node_modules), and reviewed the diff.

πŸ€– Generated with Claude Code

…tokens, bound the registry

Four confirmed mesh/bridge holes, each verified with live probes.

1. completeCommand device-spoofing bypass (core/mesh/service.ts)

   `if (from && from !== pending.deviceId)` skipped the ownership check
   entirely when a reply omitted `deviceId` β€” any connected client could
   answer another device's pending command and feed the model fabricated
   exec stdout or a fake install success. A missing deviceId is now dropped
   like a mismatched one.

   The alternative (accept an unattributed reply when exactly one command is
   pending) was considered and rejected: it still cannot attribute the reply,
   it only narrows the window to whenever the mesh is idle β€” which is most of
   the time β€” while looking like a check. Nothing real is lost: `deviceId`
   has always been part of the command-result wire contract
   (protocol/fixtures/mesh_v1.json, asserted by daemon, talon-node and the
   companion) and both shipped clients send it. An unattributable reply now
   leaves the command to time out honestly instead of resolving with someone
   else's data.

2. Device commands no longer broadcast to every SSE client

   `device_command` params carry one-time transfer tokens, exec command
   lines, remote paths and β€” on the chunked fallback β€” the entire base64 file
   body. They went to every connected client, with "each client filters by
   its own device id" as the only protection: courtesy, not enforcement.

   A client now names itself on connect (`GET /events?deviceId=…`), the
   bridge keeps the claim per connection, and `BridgeServer.sendToDevice`
   delivers a command only to the client(s) that claimed the target. `locate`
   still fans out β€” it carries no secret and pre-command app builds depend on
   it. Clients that claim nothing remain reachable: they receive a command
   only when nobody claimed its target, so an updated device's traffic is
   never visible to them and the fallback shrinks as the fleet updates. The
   claim is an address, not a credential β€” the bridge bearer token is still
   the trust boundary; what changes is that a device stops passively
   receiving its peers' secrets.

   talon-node and the companion both claim their id now (apps/node/bridge.go,
   companion BridgeClient.meshDeviceId β†’ ConnectionConfig.eventsUrl).

3. Transfer tokens bound to the device they were minted for

   TransferStore.take() validated token/direction/expiry but never who was
   redeeming. It now refuses a caller that names a different device than the
   token was minted for, before the single-use latch β€” so a wrong claim
   cannot burn the real device's token either. /devices/file passes the
   caller's `deviceId`; both in-repo clients send it.

   A caller that claims nothing is still served, deliberately: requiring the
   claim would break the transfer that ships the client build able to make it
   (update_device/update_node push the new binary over `download_file`), so a
   daemon upgraded ahead of its fleet would refuse the very transfer that
   updates the fleet. The token still only reaches the target device over the
   now-addressed command channel, is single-use, and expires in 10 minutes.
   Once the fleet claims, one line makes the binding mandatory.

4. Registry growth bounded (core/mesh/registry.ts)

   sanitizeDevice bounded only arch/capabilities: id, name and appVersion
   rode in unbounded up to the 256KB body cap and were persisted forever.
   Names and versions are now clamped (display-only β€” a silly name costs the
   label, not the registration); an over-long id is refused outright, since
   truncating an identity key would quietly merge two devices onto one entry.
   Location deviceId and provider get the same treatment.

   The device map is capped at 128, evicting the least-recently-seen entry
   (with its location + history) rather than refusing newcomers β€” a live
   device re-registers within ~60s and takes its slot back. With the existing
   per-device history bound this also caps the history sidecar.

   storeLocation accepted any deviceId, registered or not, each call
   rewriting all three sidecars. Unregistered ids are still accepted (a fix
   can arrive just before the first registration lands) but the orphan set is
   capped at 16, stalest-first, and history keys belonging to neither a
   device nor a location are swept. Both caps also run on load(), so an
   oversized sidecar is a one-boot problem rather than permanent state.

Tests (all through the real code paths): an unattributed command result is
dropped and the command times out; a transfer token is refused under another
device's name over real HTTP without burning the token, in both directions;
two live SSE clients prove a command reaches only the claiming one while
broadcasts still reach everyone, and that unclaimed clients keep working
without seeing claimed traffic; registry caps for over-long ids, clamped
display fields, device eviction and orphan locations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claudiusthebot
claudiusthebot merged commit 609d9b6 into main Jul 25, 2026
53 checks passed
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