Skip to content

feat(server): wire-input hardening — frame-shape guards, inbound limits, sanitized execute errors (ADR-0012)#15

Merged
grrowl merged 4 commits into
mainfrom
fix/005-wire-input-hardening
Jun 13, 2026
Merged

feat(server): wire-input hardening — frame-shape guards, inbound limits, sanitized execute errors (ADR-0012)#15
grrowl merged 4 commits into
mainfrom
fix/005-wire-input-hardening

Conversation

@grrowl

@grrowl grrowl commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Lands plan-005 on main (folded into the 0.3.2 bug-fix batch — see version note below).

Hardens the DO's wire boundary (ADR-0012):

  • Frame-shape guards — malformed/short frames are dropped; the socket survives and answers the next valid frame.
  • Inbound limits — oversized frames and a per-connection subscription cap are enforced (exercised via a LimitsTestDO subclass).
  • Sanitized execute errors — a mutation execute throw returns a generic "mutation failed"; SQLite/internal detail is no longer leaked to the client. Authorize errors still pass through (intentional). Updates 3 assertions in error-paths.test.ts accordingly.

ADR numbering: kept as 0012 (not renumbered to main's next-free 0011). main has no 0011 — that's feat/ssr's SSR ADR. Keeping 0012 means feat/ssr's identical 0012 aligns on rebase and its 0011=SSR fills the gap, avoiding a renumber collision at reconciliation. main's index intentionally jumps 0010→0012 until SSR lands.

Semver note: this is technically a feat (new guard behavior), normally a minor. Per maintainer steer ("not precious / one big patch is fine"), it rides the 0.3.2 patch. Say the word to split it out instead.

Validated on main + published @tanstack/db 0.6.5: typecheck + full suite (160 passed, incl. wire-hardening.test.ts 5/5) + build green. Incl. the codex-review bigint-stringify fix. Supersedes feat/ssr PR #9.

🤖 Generated with Claude Code

grrowl and others added 4 commits June 13, 2026 14:09
Per ADR-0012 (to be written):
- wellFormed() shape-guard runs after decode, before any SQL binding so no
  arbitrary decoded value reaches lookupTx or sql.exec. Drops + logs bad
  frames (mirrors the undecodable-frame stance).
- maxFrameBytes (1 MiB) checked before decode; mirrors Cloudflare's own cap
  and makes the bound testable/overrideable.
- maxOpsPerMutation (128): reject-don't-truncate at the top of handleMut;
  a partial apply would silently drop client writes.
- maxSubsPerSocket (256): cap checked in handleSub; re-subs (same subId)
  replace rather than count against the cap (SubscriptionRegistry.add
  semantics). Over-limit → reset + console.error.
- SubscriptionRegistry.countFor(ws) accessor added for cap enforcement.
- Execute errors sanitized: full detail logged server-side, generic
  "mutation failed"/"command failed" + EXECUTE_FAILED sent to client.
  Authorize errors in handleMut kept user-facing unchanged (README API).
- plan-001 error-paths test updated: command boom test now asserts the
  generic message/code and verifies the detail does NOT leak.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds tests/wire-hardening.test.ts pinning the ADR-0012 invariants:
1. Malformed frame shapes are silently dropped; socket survives.
2. ops over maxOpsPerMutation → LIMIT_EXCEEDED, nothing applied.
3. Subscription cap: third sub on 2-cap DO gets reset; first two
   subs continue receiving deltas.
4. Oversized frame (>1 MiB) dropped before decode; socket survives
   (workerd passed the frame to webSocketMessage — the DO's own
   maxFrameBytes guard fired at 1,048,666 bytes).
5. Execute error → generic "mutation failed"/EXECUTE_FAILED; SQLite
   constraint detail does not leak; authorize error passes through.

Also fixes the wellFormed shape guard to treat null == absent for
optional fields (the client transport sends null, not omit, for
absent fields in MessagePack serialisation — previous commit missed
this and broke all existing sub-bearing tests on the full suite).

LimitsTestDO (maxOpsPerMutation=2, maxSubsPerSocket=2) added to
tests/test-worker.ts, wired in vitest.config.ts and tests/env.d.ts,
following the MaintTestDO/SlowTickDO pattern exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records decisions from plan-005: shape-guard drop policy (drop+log, no
reply — mirrors undecodable bytes), the three limits and their
defaults/override pattern, execute-error sanitization with authorize
exempt (mut path), and the explicit deferral of dedup identity binding.

Also applies one finding from the codex adversarial review (gpt-5.5):

  Finding 3 — APPLIED: JSON.stringify(decoded) in the wellFormed drop
  path could throw on bigint values (MessagePack useBigInt64 may decode
  bigints). Fixed by using a replacer that stringifies bigints as strings,
  with a fallback to String(decoded) on any other error. The drop+log
  behaviour is now crash-safe for all decoded values.

Rebuttals for findings NOT applied:

  Finding 1 — NOT a gap: replay stores the SANITIZED message ("mutation
  failed") because rejectTx writes the generic string to recordTx before
  the dedup record is written. Old records (pre-upgrade) with SQLite detail
  would only replay if they exist at deploy time; that is a deploy-time
  concern, not a code gap.

  Finding 2 — BY DESIGN: "no mutation handler for '...:...'" is the
  authorize-path catch, kept user-facing per the plan (README: "throw to
  deny"). Exposing the collection:type name is consistent with the library's
  author-configuring-the-DO model; the attacker is already authed. Noted
  in ADR for future review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test 4 ("oversized frame is dropped") previously only filtered for
`rejected` frames referencing txId "wh-big1", which passes even when
the guard is disabled (the DO processes the payload and emits
`committed`, not `rejected`).

Two stronger assertions added:
- `expect(droppedFrames).toHaveLength(0)` — this socket has no
  subscriptions so ANY frame arriving signals the guard failed;
  a `committed` frame now causes the test to fail correctly.
- `runInDurableObject` DB check: `SELECT COUNT(*) FROM messages
  WHERE id='big'` must be 0 — the strongest signal the write
  was never applied.

Verified falsifiability: temporarily setting `if (false && byteLen >
this.maxFrameBytes)` causes the test to fail with
"expected [{ t: 'committed' }] to have length 0"; restoring the guard
makes it pass. Full suite: 183 tests, 43 files, all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@grrowl grrowl merged commit 650b4a0 into main Jun 13, 2026
1 check passed
grrowl added a commit that referenced this pull request Jun 13, 2026
Bug-fix batch from the deep-audit follow-up (audit-excluded SSR topic aside):
client reconnect-wedge fix (#12), wire-input hardening + error sanitization
(#15, ADR-0012), catch-up N+1 batching (#14), plus expanded error/wire test
coverage (#11) and dead-code/identifier-quoting cleanup (#13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
grrowl added a commit that referenced this pull request Jun 13, 2026
- transport: express the failed-open reconnect recovery (#12) via the
  existing scheduleReconnect() rather than the inline form main carried
  (main had no such method; feat/ssr does) — same behavior, one path.
- regenerate package-lock against the merged package.json (0.4.0-dev.0,
  vendored PR-1564 tarballs).

The 0.3.2 fixes (#11#15) now coexist with the SSR feature: readSyncSnapshot
+ readChangesSinceFor + wire guards in sync-do; highWaterSeq + per-table
indexed reads + quoted trigger DDL in changes; ADR index carries 0011+0012.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
grrowl added a commit that referenced this pull request Jun 13, 2026
- transport: express the failed-open reconnect recovery (#12) via the
  existing scheduleReconnect() rather than the inline form main carried
  (main had no such method; feat/ssr does) — same behavior, one path.
- regenerate package-lock against the merged package.json (0.4.0-dev.0,
  vendored PR-1564 tarballs).

The 0.3.2 fixes (#11#15) now coexist with the SSR feature: readSyncSnapshot
+ readChangesSinceFor + wire guards in sync-do; highWaterSeq + per-table
indexed reads + quoted trigger DDL in changes; ADR index carries 0011+0012.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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