Skip to content

Commit 73bf713

Browse files
benvinegarclaude
andauthored
fix(session): cap daemon HTTP, websocket, and registration payload sizes (#333)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d77a095 commit 73bf713

11 files changed

Lines changed: 390 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ All notable user-visible changes to Hunk are documented in this file.
1212

1313
- Hardened the local session daemon against browser-originated requests by validating Host and Origin headers and requiring JSON content types for API posts.
1414
- Disabled the generic broker HTTP API by default so Hunk's supported session API is the only app-daemon command surface.
15+
- Bounded session daemon memory by capping HTTP request body and websocket message sizes and rejecting session registrations with oversized file, hunk, patch, comment, or note payloads.
1516

1617
## [0.13.0] - 2026-05-18
1718

packages/session-broker-bun/src/serve.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { SessionServerMessage } from "@hunk/session-broker-core";
1+
import {
2+
MAX_WS_MESSAGE_BYTES,
3+
utf8ByteLength,
4+
type SessionServerMessage,
5+
} from "@hunk/session-broker-core";
26
import type { SessionBrokerDaemon } from "@hunk/session-broker";
37

48
export interface ServeSessionBrokerDaemonOptions<
@@ -88,11 +92,20 @@ export function serveSessionBrokerDaemon<
8892
return (await options.notFound?.(request)) ?? defaultNotFound();
8993
},
9094
websocket: {
95+
// Let Bun reject oversized frames at the protocol layer before they are ever buffered.
96+
maxPayloadLength: MAX_WS_MESSAGE_BYTES,
9197
message: (socket, message) => {
9298
if (typeof message !== "string") {
9399
return;
94100
}
95101

102+
// Defense in depth: Bun's maxPayloadLength already bounds raw frames, but guard the
103+
// decoded string too so a registration payload cannot be parsed unbounded here.
104+
if (utf8ByteLength(message) > MAX_WS_MESSAGE_BYTES) {
105+
socket.close(1009, "Message exceeds the session broker size limit.");
106+
return;
107+
}
108+
96109
options.daemon.handleConnectionMessage(socket, message);
97110
},
98111
close: (socket) => {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./types";
22
export * from "./brokerWire";
3+
export * from "./limits";
34
export * from "./brokerState";
45
export * from "./selectors";
56
export * from "./sessionTerminalMetadata";
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { PayloadTooLargeError, readRequestTextWithLimit, utf8ByteLength } from "./limits";
3+
4+
/** Build a streaming request body so the read path runs without a Content-Length header. */
5+
function streamingRequest(byteLength: number, chunkSize = 64 * 1024) {
6+
const stream = new ReadableStream<Uint8Array>({
7+
pull(controller) {
8+
const remaining = byteLength - sent;
9+
if (remaining <= 0) {
10+
controller.close();
11+
return;
12+
}
13+
14+
const size = Math.min(chunkSize, remaining);
15+
controller.enqueue(new Uint8Array(size).fill(120));
16+
sent += size;
17+
},
18+
});
19+
let sent = 0;
20+
21+
return new Request("http://broker.test/api", {
22+
method: "POST",
23+
body: stream,
24+
// Bun requires half-duplex opt-in for streamed request bodies.
25+
duplex: "half",
26+
} as RequestInit);
27+
}
28+
29+
describe("readRequestTextWithLimit", () => {
30+
test("rejects an oversized declared Content-Length before reading the body", async () => {
31+
const request = new Request("http://broker.test/api", {
32+
method: "POST",
33+
headers: { "content-type": "application/json", "content-length": String(10 * 1024 * 1024) },
34+
body: "ignored",
35+
});
36+
37+
await expect(readRequestTextWithLimit(request, 1024)).rejects.toBeInstanceOf(
38+
PayloadTooLargeError,
39+
);
40+
});
41+
42+
test("aborts the stream when a missing Content-Length hides an oversized body", async () => {
43+
const request = streamingRequest(2 * 1024 * 1024);
44+
45+
await expect(readRequestTextWithLimit(request, 256 * 1024)).rejects.toBeInstanceOf(
46+
PayloadTooLargeError,
47+
);
48+
});
49+
50+
test("returns the decoded body when it stays under the limit", async () => {
51+
const request = new Request("http://broker.test/api", {
52+
method: "POST",
53+
headers: { "content-type": "application/json" },
54+
body: JSON.stringify({ action: "list" }),
55+
});
56+
57+
await expect(readRequestTextWithLimit(request, 1024 * 1024)).resolves.toBe(
58+
JSON.stringify({ action: "list" }),
59+
);
60+
});
61+
62+
test("treats a missing body as an empty string", async () => {
63+
const request = new Request("http://broker.test/api", { method: "GET" });
64+
65+
await expect(readRequestTextWithLimit(request, 1024)).resolves.toBe("");
66+
});
67+
});
68+
69+
describe("utf8ByteLength", () => {
70+
test("counts multi-byte characters by their encoded size", () => {
71+
expect(utf8ByteLength("abc")).toBe(3);
72+
expect(utf8ByteLength("é")).toBe(2);
73+
expect(utf8ByteLength("😀")).toBe(4);
74+
});
75+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Hard size ceilings for everything the session broker parses or stores from the network.
3+
*
4+
* The broker is loopback-only by default, but a hostile or buggy local process (and any remote
5+
* peer when HUNK_MCP_UNSAFE_ALLOW_REMOTE=1) can otherwise stream unbounded HTTP bodies or
6+
* websocket frames, or register a changeset with an unbounded number of files, hunks, comments,
7+
* or patch bytes. These caps keep memory bounded while staying far above any realistic review.
8+
*/
9+
10+
/** Maximum decoded byte length accepted for one HTTP API request body. */
11+
export const MAX_HTTP_BODY_BYTES = 4 * 1024 * 1024;
12+
13+
/** Maximum byte length accepted for one inbound websocket message. */
14+
export const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
15+
16+
/** Maximum number of files accepted in one session registration payload. */
17+
export const MAX_REGISTRATION_FILES = 5_000;
18+
19+
/** Maximum number of hunks accepted per registered file. */
20+
export const MAX_REGISTRATION_HUNKS_PER_FILE = 10_000;
21+
22+
/** Maximum byte length accepted for one registered file's patch text. */
23+
export const MAX_REGISTRATION_PATCH_BYTES = 2 * 1024 * 1024;
24+
25+
/** Maximum number of live comments accepted in one session snapshot. */
26+
export const MAX_SNAPSHOT_LIVE_COMMENTS = 10_000;
27+
28+
/** Maximum number of review notes accepted in one session snapshot. */
29+
export const MAX_SNAPSHOT_REVIEW_NOTES = 10_000;
30+
31+
/** Raised when an inbound payload exceeds its configured byte budget. */
32+
export class PayloadTooLargeError extends Error {
33+
constructor(readonly limitBytes: number) {
34+
super(`Payload exceeds the ${limitBytes}-byte session broker limit.`);
35+
this.name = "PayloadTooLargeError";
36+
}
37+
}
38+
39+
// Reused across every websocket message, HTTP body, and patch check to avoid a per-call alloc.
40+
const sharedTextEncoder = new TextEncoder();
41+
42+
/** UTF-8 byte length of a string without allocating a Buffer in non-Node runtimes. */
43+
export function utf8ByteLength(value: string): number {
44+
return sharedTextEncoder.encode(value).length;
45+
}
46+
47+
/**
48+
* Read one request body as text while enforcing a hard byte ceiling.
49+
*
50+
* The Content-Length header is rejected early when it already declares an oversized body, and the
51+
* stream is aborted mid-read so a missing or lying Content-Length cannot force the daemon to
52+
* buffer an unbounded body before the cap is noticed.
53+
*/
54+
export async function readRequestTextWithLimit(
55+
request: Request,
56+
maxBytes: number,
57+
): Promise<string> {
58+
const declared = request.headers.get("content-length");
59+
if (declared) {
60+
const length = Number.parseInt(declared, 10);
61+
if (Number.isInteger(length) && length > maxBytes) {
62+
throw new PayloadTooLargeError(maxBytes);
63+
}
64+
}
65+
66+
const body = request.body;
67+
if (!body) {
68+
// Some runtimes do not expose a streaming body; the Content-Length guard above still bounds
69+
// well-behaved clients, and the post-read check bounds the rest.
70+
const text = await request.text();
71+
if (utf8ByteLength(text) > maxBytes) {
72+
throw new PayloadTooLargeError(maxBytes);
73+
}
74+
75+
return text;
76+
}
77+
78+
const reader = body.getReader();
79+
const chunks: Uint8Array[] = [];
80+
let total = 0;
81+
for (;;) {
82+
let done: boolean;
83+
let value: Uint8Array | undefined;
84+
try {
85+
const result = await reader.read();
86+
done = result.done;
87+
value = result.value;
88+
} catch (error) {
89+
reader.releaseLock();
90+
throw error;
91+
}
92+
93+
if (done) {
94+
break;
95+
}
96+
97+
if (!value) {
98+
continue;
99+
}
100+
101+
total += value.byteLength;
102+
if (total > maxBytes) {
103+
// Stop pulling from the stream immediately so the body cannot grow past the cap.
104+
await reader.cancel().catch(() => {});
105+
// cancel() does not release the lock per the Streams spec; release it explicitly so the
106+
// over-limit path matches the normal-exit path instead of waiting for GC.
107+
reader.releaseLock();
108+
throw new PayloadTooLargeError(maxBytes);
109+
}
110+
111+
chunks.push(value);
112+
}
113+
114+
reader.releaseLock();
115+
116+
const merged = new Uint8Array(total);
117+
let offset = 0;
118+
for (const chunk of chunks) {
119+
merged.set(chunk, offset);
120+
offset += chunk.byteLength;
121+
}
122+
123+
return new TextDecoder().decode(merged);
124+
}

packages/session-broker/src/daemon.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,29 @@ describe("session broker daemon", () => {
199199
daemon.shutdown();
200200
});
201201

202+
test("rejects raw broker API bodies that exceed the size limit", async () => {
203+
const daemon = createSessionBrokerDaemon({
204+
broker: createBroker(),
205+
capabilities: { version: 1 },
206+
exposeHttpApi: true,
207+
});
208+
209+
const oversized = JSON.stringify({ action: "list", filler: "x".repeat(5 * 1024 * 1024) });
210+
const response = await daemon.handleRequest(
211+
new Request("http://broker.test/broker", {
212+
method: "POST",
213+
headers: { "content-type": "application/json" },
214+
body: oversized,
215+
}),
216+
);
217+
218+
expect(response?.status).toBe(413);
219+
await expect(response?.json()).resolves.toMatchObject({
220+
error: expect.stringContaining("session broker limit"),
221+
});
222+
daemon.shutdown();
223+
});
224+
202225
test("dispatches one raw command through the broker API", async () => {
203226
const daemon = createSessionBrokerDaemon({
204227
broker: createBroker(),

packages/session-broker/src/daemon.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { SessionServerMessage, SessionTargetSelector } from "@hunk/session-broker-core";
1+
import {
2+
MAX_HTTP_BODY_BYTES,
3+
PayloadTooLargeError,
4+
readRequestTextWithLimit,
5+
type SessionServerMessage,
6+
type SessionTargetSelector,
7+
} from "@hunk/session-broker-core";
28
import type { SessionBrokerController, SessionBrokerPeer } from "./broker";
39
import {
410
DEFAULT_SESSION_BROKER_API_PATH,
@@ -64,8 +70,9 @@ function hasJsonContentType(request: Request) {
6470
async function parseJsonRequest<CommandName extends string = string, CommandInput = unknown>(
6571
request: Request,
6672
) {
73+
const text = await readRequestTextWithLimit(request, MAX_HTTP_BODY_BYTES);
6774
try {
68-
return (await request.json()) as SessionBrokerDaemonRequest<CommandName, CommandInput>;
75+
return JSON.parse(text) as SessionBrokerDaemonRequest<CommandName, CommandInput>;
6976
} catch {
7077
throw new Error("Expected one JSON request body.");
7178
}
@@ -369,6 +376,10 @@ export class SessionBrokerDaemon<
369376

370377
return Response.json(response);
371378
} catch (error) {
379+
if (error instanceof PayloadTooLargeError) {
380+
return jsonError(error.message, 413);
381+
}
382+
372383
return jsonError(error instanceof Error ? error.message : "Unknown broker API error.");
373384
}
374385
}

0 commit comments

Comments
 (0)