fix(session): cap daemon HTTP, websocket, and registration payload sizes#333
Conversation
Greptile SummaryThis PR adds hard size ceilings to every inbound data path the session broker processes: HTTP request bodies (4 MiB), WebSocket frames (8 MiB), and registration/snapshot payloads (files, hunks, patch text, comments, notes). The limits are centralized in a new
Confidence Score: 4/5Safe to merge; the enforcement logic is correct across all three transport paths and is well-tested. Two small cleanup items in limits.ts: reader.releaseLock() is not called after reader.cancel() on the over-limit path, and utf8ByteLength allocates a fresh TextEncoder per call rather than reusing a module-level instance. packages/session-broker-core/src/limits.ts — the streaming-read over-limit path and the utf8ByteLength helper. Important Files Changed
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
packages/session-broker-core/src/limits.ts:98-103
The reader lock is not released before throwing on the over-limit path. `reader.cancel()` cancels the underlying source but does not release the lock per the WHATWG Streams spec — only `releaseLock()` does that. In practice the lock is freed when the reader is GC'd (since the request object goes out of scope after the 413 response), but the lock should be released explicitly to match the normal-exit path and avoid holding it for the GC cycle.
```suggestion
total += value.byteLength;
if (total > maxBytes) {
// Stop pulling from the stream immediately so the body cannot grow past the cap.
await reader.cancel().catch(() => {});
reader.releaseLock();
throw new PayloadTooLargeError(maxBytes);
}
```
### Issue 2 of 2
packages/session-broker-core/src/limits.ts:39-42
`utf8ByteLength` allocates a fresh `TextEncoder` on every invocation. It is called per WebSocket message, per HTTP body, and per file patch. Hoisting a single module-level encoder avoids the per-call allocation entirely.
```suggestion
const _encoder = new TextEncoder();
/** UTF-8 byte length of a string without allocating a Buffer in non-Node runtimes. */
export function utf8ByteLength(value: string): number {
return _encoder.encode(value).length;
}
```
Reviews (1): Last reviewed commit: "fix(session): cap daemon HTTP, websocket..." | Re-trigger Greptile |
| total += value.byteLength; | ||
| if (total > maxBytes) { | ||
| // Stop pulling from the stream immediately so the body cannot grow past the cap. | ||
| await reader.cancel().catch(() => {}); | ||
| throw new PayloadTooLargeError(maxBytes); | ||
| } |
There was a problem hiding this comment.
The reader lock is not released before throwing on the over-limit path.
reader.cancel() cancels the underlying source but does not release the lock per the WHATWG Streams spec — only releaseLock() does that. In practice the lock is freed when the reader is GC'd (since the request object goes out of scope after the 413 response), but the lock should be released explicitly to match the normal-exit path and avoid holding it for the GC cycle.
| total += value.byteLength; | |
| if (total > maxBytes) { | |
| // Stop pulling from the stream immediately so the body cannot grow past the cap. | |
| await reader.cancel().catch(() => {}); | |
| throw new PayloadTooLargeError(maxBytes); | |
| } | |
| total += value.byteLength; | |
| if (total > maxBytes) { | |
| // Stop pulling from the stream immediately so the body cannot grow past the cap. | |
| await reader.cancel().catch(() => {}); | |
| reader.releaseLock(); | |
| throw new PayloadTooLargeError(maxBytes); | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/session-broker-core/src/limits.ts
Line: 98-103
Comment:
The reader lock is not released before throwing on the over-limit path. `reader.cancel()` cancels the underlying source but does not release the lock per the WHATWG Streams spec — only `releaseLock()` does that. In practice the lock is freed when the reader is GC'd (since the request object goes out of scope after the 413 response), but the lock should be released explicitly to match the normal-exit path and avoid holding it for the GC cycle.
```suggestion
total += value.byteLength;
if (total > maxBytes) {
// Stop pulling from the stream immediately so the body cannot grow past the cap.
await reader.cancel().catch(() => {});
reader.releaseLock();
throw new PayloadTooLargeError(maxBytes);
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Done — added an explicit reader.releaseLock() after cancel() on the over-limit path so it matches the normal-exit path instead of waiting for GC.
Responded by Claude Code using claude-opus-4-7.
| /** UTF-8 byte length of a string without allocating a Buffer in non-Node runtimes. */ | ||
| export function utf8ByteLength(value: string): number { | ||
| return new TextEncoder().encode(value).length; | ||
| } |
There was a problem hiding this comment.
utf8ByteLength allocates a fresh TextEncoder on every invocation. It is called per WebSocket message, per HTTP body, and per file patch. Hoisting a single module-level encoder avoids the per-call allocation entirely.
| /** UTF-8 byte length of a string without allocating a Buffer in non-Node runtimes. */ | |
| export function utf8ByteLength(value: string): number { | |
| return new TextEncoder().encode(value).length; | |
| } | |
| const _encoder = new TextEncoder(); | |
| /** UTF-8 byte length of a string without allocating a Buffer in non-Node runtimes. */ | |
| export function utf8ByteLength(value: string): number { | |
| return _encoder.encode(value).length; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/session-broker-core/src/limits.ts
Line: 39-42
Comment:
`utf8ByteLength` allocates a fresh `TextEncoder` on every invocation. It is called per WebSocket message, per HTTP body, and per file patch. Hoisting a single module-level encoder avoids the per-call allocation entirely.
```suggestion
const _encoder = new TextEncoder();
/** UTF-8 byte length of a string without allocating a Buffer in non-Node runtimes. */
export function utf8ByteLength(value: string): number {
return _encoder.encode(value).length;
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Done — hoisted a single module-level TextEncoder (sharedTextEncoder) and reuse it in utf8ByteLength.
Responded by Claude Code using claude-opus-4-7.
The session broker buffered HTTP bodies, websocket frames, and session registration payloads without any ceiling, so a single oversized request could drive the daemon to unbounded memory and OOM (remotely when HUNK_MCP_UNSAFE_ALLOW_REMOTE=1). Enforce hard caps as early as each transport allows: reject oversized Content-Length and stream-abort lying bodies, set Bun's websocket maxPayloadLength, and reject registrations with too many files/hunks/comments/notes or oversized patches. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Release the stream reader lock explicitly on the over-limit path and reuse a single module-level TextEncoder. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
#332 made the generic broker HTTP API opt-in (exposeHttpApi). The new body-size-limit test must enable it to reach the 413 path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4986495 to
532ca96
Compare
Summary
Closes item #4 of the session-daemon security review: no body / WebSocket message size limits. The broker buffered HTTP bodies, websocket frames, and session registration payloads with no ceiling, so a single oversized request could drive the daemon to unbounded memory / OOM — a local DoS, or a remote DoS when
HUNK_MCP_UNSAFE_ALLOW_REMOTE=1.This builds on the Host/Origin/content-type hardening from #329.
Changes
New shared
packages/session-broker-core/src/limits.tswith hard caps, enforced as early as each transport allows:readRequestTextWithLimitrejects an oversizedContent-Lengthbefore reading, and stream-aborts a missing/lyingContent-Lengthmid-read so the body is never buffered unbounded. Over-limit →413. Applied to both the generic daemon and the Hunk session API. Portable across the Bun and Node serve layers.maxPayloadLength(protocol-layer rejection) plus a decoded-string guard as defense-in-depth.Caps (named constants, easy to tune): HTTP body 4 MiB, WS message 8 MiB, 5k files, 10k hunks/file, 2 MiB/patch, 10k comments, 10k notes — all far above any realistic review.
Testing
bun run typecheck,bun run lint— cleanlimits.test.ts: Content-Length early-reject, streaming abort with no Content-Length, under-limit pass-through, empty body,utf8ByteLengthdaemon.test.ts/brokerServer.test.ts: 413 on oversized body for both API surfaceswire.test.ts: file / hunk / patch / live-comment / review-note caps reject the payload🤖 Generated with Claude Code