Skip to content

Harden inbound envelope handling and iOS attachment outbox - #8

Merged
TeoSlayer merged 1 commit into
mainfrom
sec/findings-claw-pilot
Jul 26, 2026
Merged

TeoSlayer merged 1 commit into
mainfrom
sec/findings-claw-pilot

Conversation

@TeoSlayer

Copy link
Copy Markdown
Contributor

Fixes five audit findings across the plugin wire/inbound layer and the iOS conversation view-model. Each was verified against current main before changing anything, and each fix has a test that fails before and passes after.

Changes

plugin/src/wire.ts — cumulative byte cap on reassembly (L17)

Reassembler and MediaReassembler held chunks for an in-flight id with no size bound; the only thing reclaiming them was the 60s GC sweep. Both now track bytes held per id and drop the entry when an incoming chunk would push it past the cap — 1 MiB for text, 32 MiB for media, both constructor-overridable. The media ceiling sits above the inbound pipeline's existing 25 MiB attachment cap so that cap still governs completed transfers.

Re-sending a seq replaces the held chunk, so its contribution is swapped rather than added — a repeated chunk cannot inflate the running total.

plugin/src/inbound.ts — timestamp window on the shared-secret bypass (L18)

A valid HMAC skipped the allowlist regardless of the envelope's ts. The bypass now additionally requires ts to be within hmacMaxSkewMs (default 2 minutes) of local time.

When the timestamp is outside the window, authorization falls back to the allowlist rather than dropping outright — so an allowlisted peer keeps its existing behavior, and only the secret-based bypass is gated. ts is already covered by the HMAC, so the window is what bounds how long one signed envelope stays usable.

plugin/src/allowlist.ts, config.ts — canonical address case (L19)

isValidPilotAddress accepts hex in either case, but the allowlist check was exact string equality, so a peer whose address was spelled with different casing than the configured entry was silently dropped. Entries are canonicalized at resolveAccount and inbound src addrs at decideAllowlist, with a fallback scan covering sets assembled by other callers.

Canonical form is upper-case, matching what nodeIdToAddress already emits in peer-address.ts — so addresses derived from either path now compare equal. This also removes a hand-rolled port-strip in the HMAC branch of inbound.ts in favor of the shared helper.

ios/.../Conversation.swift — attachments through the outbox (H5, M26)

Two related gaps in the same flow:

  • drainOutbox re-sent every queued message via conn.send(text:), so a retried media message arrived as its caption alone and the bytes were lost. It now branches on m.attachments and re-sends via the media path; text-only messages are unchanged.
  • sendAttachment returned early when there was no ready connection, discarding the message entirely. It now appends and persists as .sending before checking the connection — mirroring send() — so an attachment composed offline survives an app restart and is picked up by the drain.

A wireKind helper on ChatAttachment carries the kind mapping the drain needs.

Tests

Two existing tests asserted the behavior these findings identify as wrong and were updated rather than worked around:

  • decideAllowlist > does not silently accept on a typo (case-sensitive net id) — now asserts that a case-variant spelling of an allowlisted address resolves to the canonical peer.
  • testSendAttachmentWhenNotReadyIsIgnoredtestSendAttachmentWhenNotReadyQueuesAsSending.

New coverage: reassembly caps (including that a replayed seq does not inflate the total, and that under-cap messages still assemble), the HMAC timestamp window in all four directions (fresh / stale / future / stale-but-allowlisted), allowlist case canonicalization, and the iOS offline-attachment persist + rehydrate + drain-selection path.

Verified fail-before/pass-after by reverting the sources and re-running: 7 of the new plugin tests and 19 iOS assertions fail without the fixes.

  • Plugin: 252 passed, 29 files (was 240) — npm test, plus tsc --noEmit clean
  • iOS: 150 passed, 0 failures (was 146) — swift test

Notes

MAX_TEXT_REASSEMBLY_BYTES / MAX_MEDIA_REASSEMBLY_BYTES and hmacMaxSkewMs are exported/injectable, so deployments that need different bounds can set them without a patch. The 2-minute default skew assumes roughly-synced clocks between the iOS client and the claw host; a peer with badly-skewed time will fall back to the allowlist rather than being refused.

🤖 Generated with Claude Code

plugin/wire.ts — bound reassembly state by cumulative payload size.
Reassembler and MediaReassembler now track the bytes held per in-flight
id and drop the entry when a chunk would push it past the cap (1 MiB
text / 32 MiB media, both overridable). Previously the only bound was
the 60s GC sweep. Re-sent seqs swap their contribution rather than
adding, so a replayed chunk does not inflate the running total.

plugin/inbound.ts — require a fresh `ts` for the shared-secret bypass.
A valid HMAC now only skips the allowlist when the envelope timestamp
is within hmacMaxSkewMs (default 2 min) of local time. Outside the
window, authorization falls back to the allowlist rather than being
denied outright, so allowlisted peers keep their previous behavior.

plugin/allowlist.ts, config.ts — canonicalize address case. The address
grammar accepts hex in either case, so an allowlisted peer spelled with
different casing than the configured entry was silently dropped.
Entries and inbound src addrs are now compared on a single canonical
form, matching what nodeIdToAddress already emits in peer-address.ts.

ios/Conversation.swift — carry attachments through the outbox.
drainOutbox re-sent every queued message over the text path, so a
retried media message arrived as its caption alone; it now branches on
the attachment and uses the media path. sendAttachment also returned
early when offline, discarding the message; it now appends and persists
as .sending before checking the connection, mirroring send(), so a
queued attachment survives an app restart and is picked up on drain.

Two existing tests asserted the superseded behavior and were updated:
the allowlist case-sensitivity test and the iOS
sendAttachment-when-offline test.

Plugin: 252 tests pass (was 240). iOS: 150 tests pass (was 146).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@TeoSlayer
TeoSlayer merged commit 6c9fedd into main Jul 26, 2026
4 checks passed
@TeoSlayer
TeoSlayer deleted the sec/findings-claw-pilot branch July 26, 2026 14:12
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.

2 participants