relay: SetReadLimit on WSConn — 256 KiB per-frame cap (#29)#44
Conversation
Thread a per-frame read-size cap through NewWSConn so the policy applies at the single chokepoint both forwarders reach. Production wires 256 KiB as a const at the composition root (cmd/pyrycode-relay/main.go), mirroring the existing 30*time.Second grace literal pattern. Derivation lives in the spec; the upper bound is the protocol's message_chunk envelope (≤50 text- only messages plus routing wrapper). ServerHandler and ClientHandler gain a maxFrameBytes int64 parameter and forward it into NewWSConn. nhooyr's *websocket.Conn.SetReadLimit is called inside the constructor before the struct is returned — there is no window in which Read can fire against an uncapped conn. ws_conn_test.go: startEcho gains a cap parameter and now echoes received frames back so client-side Read can exercise the cap. TestWSConn_Read_FrameExceedingCap_ReturnsError asserts first + subsequent Read are non-nil after an over-cap frame. TestWSConn_Read_FrameAtCap_ DeliveredIntact asserts an at-cap frame round-trips intact. docs/knowledge/features/ws-conn-adapter.md: retire the "no read cap" follow-up bullet; document the 256 KiB policy under Adversarial framing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ilmoniemi
left a comment
There was a problem hiding this comment.
Code Review: #29
Decision: PASS
Findings
None.
Summary
Faithful implementation of docs/specs/architecture/29-wsconn-read-limit.md. SetReadLimit is applied inside NewWSConn before the struct is published (ws_conn.go:54) — discharges the AC's "before any Read" requirement at the single chokepoint both forwarders reach. The 256 KiB literal lives as a const in cmd/pyrycode-relay/main.go with the derivation pointer; no package-level constant in internal/relay. All eight call sites of NewWSConn, ServerHandler, and ClientHandler are updated (verified by grep); no defer additions in client_endpoint.go.
Tests are clean: startEcho is parametrised on maxFrameBytes, the over-cap test round-trips through the uncapped server back into the capped client and asserts only the non-nil contract on the first AND a subsequent Read (per AC), and the at-cap test pins the boundary on both server-side delivery and client-side echo. TestWSConn_ConnID_ReturnsConstructorValue migrated off the nil-conn pattern as the spec anticipated.
Security review (label security-sensitive): the architect's ## Security review section is present in the spec with verdict PASS and a 10-point adversarial walk. The diff is consistent with that review — no new log lines that could leak payload bytes, no new error paths, no crypto, no new deps, no // #nosec. SetReadLimit mutates pre-publication state on the constructor goroutine so there is no race. make vet, make build, and go test -race ./internal/relay/... are clean locally; CI test and security are green.
Docs: docs/knowledge/features/ws-conn-adapter.md has the "Out of scope" bullet removed and a new "Per-frame read cap" entry under § Adversarial framing, matching the spec's "make the spec true" instructions.
- knowledge/codebase/29.md: per-ticket implementation summary (cap derivation from protocol-mobile.md, constructor-site application, test parametrisation, failure semantics). - features/ws-conn-adapter.md: NewWSConn signature reflects the new maxFrameBytes parameter (the Adversarial-framing entry was added inline with the implementation commit). - PROJECT-MEMORY.md: new pattern — input-bounding policy applied at the adapter constructor, not per-handler. Distinct from the existing "policy values live at the wiring site" pattern (literal placement vs. enforcement placement). - lessons.md: nhooyr's 32 MiB default read limit + the "cap at the constructor before any Read can fire" technique. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
What
Bound every inbound WebSocket frame the relay reads from a peer by a deliberate 256 KiB per-frame cap, applied at the single chokepoint (
NewWSConn) so the policy covers both the phone-side (StartPhoneForwarder, #25) and binary-side (StartBinaryForwarder, #26) forwarders without per-handler code.NewWSConngains amaxFrameBytes int64parameter and calls*websocket.Conn.SetReadLimitbefore returning — there is no window in whichReadcan fire against an uncapped conn.ServerHandlerandClientHandlergain a matching parameter and thread it through.cmd/pyrycode-relay/main.gowires the literal at the composition root (const maxFrameBytes int64 = 256 * 1024), mirroring the existing30*time.Secondgrace pattern. No package-level constant ininternal/relay.docs/knowledge/features/ws-conn-adapter.md: retired the "no per-message size cap onRead" follow-up; added the 256 KiB policy under § Adversarial framing.Issue
Closes #29. Spec:
docs/specs/architecture/29-wsconn-read-limit.md(security review verdict: PASS).Testing
TestWSConn_Read_FrameExceedingCap_ReturnsError— round-trips an oversize frame throughstartEcho(cap=64); asserts the first AND a subsequentReadboth return non-nil. Discharges "non-nil error" + "subsequent reads fail" ACs without asserting on the library-specific error type.TestWSConn_Read_FrameAtCap_DeliveredIntact— at-cap frame (256 bytes against cap=256); asserts both the server-sidereceivedchannel and the client-side echoedReadsee the bytes intact.startEchonow parametric onmaxFrameBytesand echoes received frames back so the client-side cap can be exercised. Existing tests updated to pass256*1024.TestWSConn_ConnID_ReturnsConstructorValuerewritten to usestartEcho(passingnilwould now NPE insideSetReadLimit).heartbeat_test.go,server_endpoint_test.go,client_endpoint_test.go— mechanical signature updates.make vet,make test(race-enabled),make buildclean.Architecture compliance
pyrycode/pyrycode/docs/protocol-mobile.md§ Backfill semantics (≤50 messages permessage_chunkenvelope; text-only payloads) incmd/pyrycode-relay/main.goand the spec./v1/clientor/v1/server— the existingdefer { Unregister/ScheduleRelease; Close; log }handles the over-cap exit unchanged.🤖 Generated with Claude Code