Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable user-visible changes to Hunk are documented in this file.

- Hardened the local session daemon against browser-originated requests by validating Host and Origin headers and requiring JSON content types for API posts.
- Disabled the generic broker HTTP API by default so Hunk's supported session API is the only app-daemon command surface.
- 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.

## [0.13.0] - 2026-05-18

Expand Down
15 changes: 14 additions & 1 deletion packages/session-broker-bun/src/serve.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { SessionServerMessage } from "@hunk/session-broker-core";
import {
MAX_WS_MESSAGE_BYTES,
utf8ByteLength,
type SessionServerMessage,
} from "@hunk/session-broker-core";
import type { SessionBrokerDaemon } from "@hunk/session-broker";

export interface ServeSessionBrokerDaemonOptions<
Expand Down Expand Up @@ -88,11 +92,20 @@ export function serveSessionBrokerDaemon<
return (await options.notFound?.(request)) ?? defaultNotFound();
},
websocket: {
// Let Bun reject oversized frames at the protocol layer before they are ever buffered.
maxPayloadLength: MAX_WS_MESSAGE_BYTES,
message: (socket, message) => {
if (typeof message !== "string") {
return;
}

// Defense in depth: Bun's maxPayloadLength already bounds raw frames, but guard the
// decoded string too so a registration payload cannot be parsed unbounded here.
if (utf8ByteLength(message) > MAX_WS_MESSAGE_BYTES) {
socket.close(1009, "Message exceeds the session broker size limit.");
return;
}

options.daemon.handleConnectionMessage(socket, message);
},
close: (socket) => {
Expand Down
1 change: 1 addition & 0 deletions packages/session-broker-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./types";
export * from "./brokerWire";
export * from "./limits";
export * from "./brokerState";
export * from "./selectors";
export * from "./sessionTerminalMetadata";
75 changes: 75 additions & 0 deletions packages/session-broker-core/src/limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test";
import { PayloadTooLargeError, readRequestTextWithLimit, utf8ByteLength } from "./limits";

/** Build a streaming request body so the read path runs without a Content-Length header. */
function streamingRequest(byteLength: number, chunkSize = 64 * 1024) {
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
const remaining = byteLength - sent;
if (remaining <= 0) {
controller.close();
return;
}

const size = Math.min(chunkSize, remaining);
controller.enqueue(new Uint8Array(size).fill(120));
sent += size;
},
});
let sent = 0;

return new Request("http://broker.test/api", {
method: "POST",
body: stream,
// Bun requires half-duplex opt-in for streamed request bodies.
duplex: "half",
} as RequestInit);
}

describe("readRequestTextWithLimit", () => {
test("rejects an oversized declared Content-Length before reading the body", async () => {
const request = new Request("http://broker.test/api", {
method: "POST",
headers: { "content-type": "application/json", "content-length": String(10 * 1024 * 1024) },
body: "ignored",
});

await expect(readRequestTextWithLimit(request, 1024)).rejects.toBeInstanceOf(
PayloadTooLargeError,
);
});

test("aborts the stream when a missing Content-Length hides an oversized body", async () => {
const request = streamingRequest(2 * 1024 * 1024);

await expect(readRequestTextWithLimit(request, 256 * 1024)).rejects.toBeInstanceOf(
PayloadTooLargeError,
);
});

test("returns the decoded body when it stays under the limit", async () => {
const request = new Request("http://broker.test/api", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "list" }),
});

await expect(readRequestTextWithLimit(request, 1024 * 1024)).resolves.toBe(
JSON.stringify({ action: "list" }),
);
});

test("treats a missing body as an empty string", async () => {
const request = new Request("http://broker.test/api", { method: "GET" });

await expect(readRequestTextWithLimit(request, 1024)).resolves.toBe("");
});
});

describe("utf8ByteLength", () => {
test("counts multi-byte characters by their encoded size", () => {
expect(utf8ByteLength("abc")).toBe(3);
expect(utf8ByteLength("é")).toBe(2);
expect(utf8ByteLength("😀")).toBe(4);
});
});
124 changes: 124 additions & 0 deletions packages/session-broker-core/src/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Hard size ceilings for everything the session broker parses or stores from the network.
*
* The broker is loopback-only by default, but a hostile or buggy local process (and any remote
* peer when HUNK_MCP_UNSAFE_ALLOW_REMOTE=1) can otherwise stream unbounded HTTP bodies or
* websocket frames, or register a changeset with an unbounded number of files, hunks, comments,
* or patch bytes. These caps keep memory bounded while staying far above any realistic review.
*/

/** Maximum decoded byte length accepted for one HTTP API request body. */
export const MAX_HTTP_BODY_BYTES = 4 * 1024 * 1024;

/** Maximum byte length accepted for one inbound websocket message. */
export const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;

/** Maximum number of files accepted in one session registration payload. */
export const MAX_REGISTRATION_FILES = 5_000;

/** Maximum number of hunks accepted per registered file. */
export const MAX_REGISTRATION_HUNKS_PER_FILE = 10_000;

/** Maximum byte length accepted for one registered file's patch text. */
export const MAX_REGISTRATION_PATCH_BYTES = 2 * 1024 * 1024;

/** Maximum number of live comments accepted in one session snapshot. */
export const MAX_SNAPSHOT_LIVE_COMMENTS = 10_000;

/** Maximum number of review notes accepted in one session snapshot. */
export const MAX_SNAPSHOT_REVIEW_NOTES = 10_000;

/** Raised when an inbound payload exceeds its configured byte budget. */
export class PayloadTooLargeError extends Error {
constructor(readonly limitBytes: number) {
super(`Payload exceeds the ${limitBytes}-byte session broker limit.`);
this.name = "PayloadTooLargeError";
}
}

// Reused across every websocket message, HTTP body, and patch check to avoid a per-call alloc.
const sharedTextEncoder = new TextEncoder();

/** UTF-8 byte length of a string without allocating a Buffer in non-Node runtimes. */
export function utf8ByteLength(value: string): number {
return sharedTextEncoder.encode(value).length;
}
Comment on lines +42 to +45
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
/** 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — hoisted a single module-level TextEncoder (sharedTextEncoder) and reuse it in utf8ByteLength.

Responded by Claude Code using claude-opus-4-7.


/**
* Read one request body as text while enforcing a hard byte ceiling.
*
* The Content-Length header is rejected early when it already declares an oversized body, and the
* stream is aborted mid-read so a missing or lying Content-Length cannot force the daemon to
* buffer an unbounded body before the cap is noticed.
*/
export async function readRequestTextWithLimit(
request: Request,
maxBytes: number,
): Promise<string> {
const declared = request.headers.get("content-length");
if (declared) {
const length = Number.parseInt(declared, 10);
if (Number.isInteger(length) && length > maxBytes) {
throw new PayloadTooLargeError(maxBytes);
}
}

const body = request.body;
if (!body) {
// Some runtimes do not expose a streaming body; the Content-Length guard above still bounds
// well-behaved clients, and the post-read check bounds the rest.
const text = await request.text();
if (utf8ByteLength(text) > maxBytes) {
throw new PayloadTooLargeError(maxBytes);
}

return text;
}

const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
let done: boolean;
let value: Uint8Array | undefined;
try {
const result = await reader.read();
done = result.done;
value = result.value;
} catch (error) {
reader.releaseLock();
throw error;
}

if (done) {
break;
}

if (!value) {
continue;
}

total += value.byteLength;
if (total > maxBytes) {
// Stop pulling from the stream immediately so the body cannot grow past the cap.
await reader.cancel().catch(() => {});
// cancel() does not release the lock per the Streams spec; release it explicitly so the
// over-limit path matches the normal-exit path instead of waiting for GC.
reader.releaseLock();
throw new PayloadTooLargeError(maxBytes);
}
Comment on lines +101 to +109
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


chunks.push(value);
}

reader.releaseLock();

const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}

return new TextDecoder().decode(merged);
}
23 changes: 23 additions & 0 deletions packages/session-broker/src/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,29 @@ describe("session broker daemon", () => {
daemon.shutdown();
});

test("rejects raw broker API bodies that exceed the size limit", async () => {
const daemon = createSessionBrokerDaemon({
broker: createBroker(),
capabilities: { version: 1 },
exposeHttpApi: true,
});

const oversized = JSON.stringify({ action: "list", filler: "x".repeat(5 * 1024 * 1024) });
const response = await daemon.handleRequest(
new Request("http://broker.test/broker", {
method: "POST",
headers: { "content-type": "application/json" },
body: oversized,
}),
);

expect(response?.status).toBe(413);
await expect(response?.json()).resolves.toMatchObject({
error: expect.stringContaining("session broker limit"),
});
daemon.shutdown();
});

test("dispatches one raw command through the broker API", async () => {
const daemon = createSessionBrokerDaemon({
broker: createBroker(),
Expand Down
15 changes: 13 additions & 2 deletions packages/session-broker/src/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { SessionServerMessage, SessionTargetSelector } from "@hunk/session-broker-core";
import {
MAX_HTTP_BODY_BYTES,
PayloadTooLargeError,
readRequestTextWithLimit,
type SessionServerMessage,
type SessionTargetSelector,
} from "@hunk/session-broker-core";
import type { SessionBrokerController, SessionBrokerPeer } from "./broker";
import {
DEFAULT_SESSION_BROKER_API_PATH,
Expand Down Expand Up @@ -64,8 +70,9 @@ function hasJsonContentType(request: Request) {
async function parseJsonRequest<CommandName extends string = string, CommandInput = unknown>(
request: Request,
) {
const text = await readRequestTextWithLimit(request, MAX_HTTP_BODY_BYTES);
try {
return (await request.json()) as SessionBrokerDaemonRequest<CommandName, CommandInput>;
return JSON.parse(text) as SessionBrokerDaemonRequest<CommandName, CommandInput>;
} catch {
throw new Error("Expected one JSON request body.");
}
Expand Down Expand Up @@ -369,6 +376,10 @@ export class SessionBrokerDaemon<

return Response.json(response);
} catch (error) {
if (error instanceof PayloadTooLargeError) {
return jsonError(error.message, 413);
}

return jsonError(error instanceof Error ? error.message : "Unknown broker API error.");
}
}
Expand Down
Loading
Loading