fix(sandbox): bound the duplex request-id bytes in the codec and the broker

The duplex frame codec accepted a request or a response `id` of any length up
to the whole-frame cap. The host broker keeps one id per distinct dispatched
request for the no-replay guarantee. A provider that controls the transport
could send distinct near-frame-limit ids and force the host to retain many
gigabytes before the lifetime count ceiling, so the host adapter process could
exhaust memory.

Add a finite protocol bound for the id field:
- Export `DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES` (256 bytes). The generated
  gateway builds each id with `randomUUID()` (36 bytes), so this bound gives
  large headroom and caps the retained bytes per id.
- Reject an over-limit id as an `id_too_large` protocol error in both codec
  copies (the host codec and the embedded gateway codec). The read path returns
  the error; it never throws. The whole-frame cap stays unchanged.
- Enforce the id bound in the broker before the seen set, the pending record,
  and the forward, as defense in depth. The refusal allocates no controller,
  no timer, and no forward, and records no telemetry.
- State the worst-case retained id bytes at the lifetime-limit definition
  (50,000 * 256 = 12,800,000 bytes).

Add shared fixture vectors for a maximal-allowed id and an over-limit id, so
both codec copies prove the same bound. Add regression tests for the flood,
the no-replay path, and the byte-not-character measure.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Priya RamanandPaperclip committed 2026-08-20 05:40:14 +00:00
1 parent fc1f74131e
commit 8036019807
6 files changed
+207 -2

No files matched your search

@@ -7,6 +7,7 @@ import {
type DuplexBrokerForwardResult,
} from "./duplex-bridge-broker.js";
import {
DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES,
DUPLEX_FRAME_VERSION,
DuplexFrameDecoder,
encodeDuplexFrame,
@@ -317,4 +318,53 @@ describe("duplex bridge broker request limits", () => {
expect(broker.lossRecord).toBeNull();
expect(broker.state).toBe("open");
});
it("forwards nothing and fails the channel closed under a flood of over-limit ids", () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
});
brokers.push(broker);
broker.start();
// Send a sustained flood of distinct ids, each one byte over the id bound. The
// codec rejects each frame on the read path, so no over-limit id ever reaches
// the retained-id set or a forward.
const overLimitId = (index: number): string =>
`${"a".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES)}-${index}`;
for (let index = 0; index < 20; index += 1) {
harness.feed(requestFrame(overLimitId(index)));
}
// The broker forwarded nothing. The retained-id set never grew, because the
// set only grows on a dispatched forward, and the broker dispatched none.
expect(harness.forwards).toHaveLength(0);
// The first over-limit frame is a protocol failure, so the broker fails the
// whole channel closed. It stays lost for the rest of the flood.
expect(broker.state).toBe("lost");
expect(broker.lossRecord?.reason).toBe("protocol_failure");
});
it("forwards a maximal-size id exactly once and does not forward a resend (no-replay preserved)", () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
});
brokers.push(broker);
broker.start();
// An id at the maximum byte size is allowed. The broker forwards it one time.
const maxId = "a".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES);
harness.feed(requestFrame(maxId));
expect(harness.forwards.filter((forward) => forward.id === maxId)).toHaveLength(1);
// Deliver the response, then resend the same id. The broker retained the id,
// so the resend never reaches the forward a second time.
harness.resolveForward(maxId);
harness.feed(requestFrame(maxId));
expect(harness.forwards.filter((forward) => forward.id === maxId)).toHaveLength(1);
expect(broker.state).toBe("open");
});
});
@@ -43,6 +43,7 @@
import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js";
import {
DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES,
DUPLEX_FRAME_VERSION,
DuplexFrameDecoder,
encodeDuplexFrame,
@@ -102,6 +103,12 @@ export const DEFAULT_DUPLEX_BROKER_MAX_IN_FLIGHT_REQUESTS = 64;
* id and forwards nothing more. This limit bounds the retained request-id memory,
* because the broker keeps one id per distinct dispatched request for the no-replay
* guarantee.
*
* The retained id memory has a hard ceiling. The codec bounds each id at
* `DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES` (256 bytes), so the worst-case retained id
* bytes are this count multiplied by that bound: 50,000 * 256 = 12,800,000 bytes
* (about 12.8 MB), plus the fixed per-entry overhead of the Set. This count is
* sized against that id bound to keep the ceiling small.
*/
export const DEFAULT_DUPLEX_BROKER_MAX_LIFETIME_REQUESTS = 50_000;
@@ -410,6 +417,18 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
const dispatch = (frame: DuplexRequestFrame): void => {
// Dispatch only while open. After loss or close the broker forwards nothing.
if (state !== "open") return;
// Bound the id byte size before any retention or work. The codec already
// rejects an over-limit id on the read path, so this guard is defense in depth
// for a frame that reaches dispatch by another path. The broker never adds the
// id to the seen set, never allocates a controller or a timer, and never
// forwards. It answers with the bounded terminal refusal, which carries no
// route, query, body, or token, and records no telemetry. The refusal is not
// retryable, because a resend of the same over-limit id never gets past this
// bound.
if (Buffer.byteLength(frame.id, "utf8") > DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES) {
respondSaturated(frame.id, false);
return;
}
// Forward one id one time. A repeated id never reaches the API twice.
if (seenRequestIds.has(frame.id)) return;
// Bound the retained request-id memory. The broker keeps one id per distinct
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DEFAULT_MAX_DUPLEX_FRAME_BYTES,
DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES,
DUPLEX_FRAME_VERSION,
DuplexFrameDecoder,
decodeDuplexLine,
@@ -17,7 +18,14 @@ type ExpectedResult = { frame: DuplexFrame } | { error: string };
interface Vector {
name: string;
category: "valid" | "invalid" | "partial" | "oversized" | "versionMismatch";
category:
| "valid"
| "invalid"
| "partial"
| "oversized"
| "versionMismatch"
| "idBoundValid"
| "idBoundInvalid";
bytes: string;
splitByteOffsets?: number[];
maxFrameBytes?: number;
@@ -242,3 +250,63 @@ describe("streaming decoder behavior", () => {
expect(results[0].ok).toBe(false);
});
});
describe("request id byte bound", () => {
// The id byte bound caps the memory the host broker retains per distinct
// request. The bound is 256 bytes; a UTF-8 id byte can differ from a character.
function requestWithId(id: string): string {
return JSON.stringify({
version: DUPLEX_FRAME_VERSION,
type: "request",
id,
method: "GET",
path: "/",
query: "",
headers: {},
body: "",
});
}
it("accepts an id at the maximum byte size", () => {
const id = "a".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES);
const result = decodeDuplexLine(requestWithId(id));
expect(result.ok).toBe(true);
if (result.ok && result.frame.type === "request") expect(result.frame.id).toBe(id);
});
it("rejects an over-limit id with an id_too_large protocol error and never throws", () => {
const id = "a".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES + 1);
expect(() => decodeDuplexLine(requestWithId(id))).not.toThrow();
const result = decodeDuplexLine(requestWithId(id));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.code).toBe("id_too_large");
});
it("measures the id in bytes, not characters, so a multi-byte id over the byte bound is rejected", () => {
// Each "😀" is four UTF-8 bytes. 65 code points make 260 bytes, one code point
// makes 4 bytes, so 65 stays over the 256-byte bound while the character count
// stays under it.
const id = "😀".repeat(65);
expect(id.length).toBeLessThan(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES);
const result = decodeDuplexLine(requestWithId(id));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.code).toBe("id_too_large");
});
it("applies the same bound to the response id", () => {
const id = "b".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES + 1);
const result = decodeDuplexLine(
JSON.stringify({
version: DUPLEX_FRAME_VERSION,
type: "response",
id,
status: 200,
headers: {},
body: "",
outcome: "completed",
}),
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.code).toBe("id_too_large");
});
});
@@ -27,6 +27,20 @@ export const DUPLEX_FRAME_VERSION = 1;
*/
export const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 1_000_000;
/**
* The maximum size of the frame `id` field, in bytes. The decoder rejects a
* request or a response frame that carries a longer id with an `id_too_large`
* protocol error. The read path returns the error; it never throws.
*
* The generated gateway builds each request id with `randomUUID()`, so a real id
* is 36 ASCII bytes. This bound of 256 bytes gives large headroom for that id and
* for any short future id scheme. The bound also caps the bytes the host broker
* retains per distinct request. The broker keeps one id per distinct dispatched
* request for the no-replay guarantee, so the id bound sets the per-id ceiling of
* that retained memory.
*/
export const DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES = 256;
const NEWLINE_BYTE = 0x0a;
const EMPTY = Buffer.alloc(0);
@@ -119,7 +133,8 @@ export type DuplexProtocolErrorCode =
| "malformed_frame"
| "unknown_type"
| "version_mismatch"
| "frame_too_large";
| "frame_too_large"
| "id_too_large";
/** A decode-time protocol error. The read path returns it; it never throws. */
export interface DuplexProtocolError {
@@ -158,6 +173,11 @@ function isStringRecord(value: unknown): value is Record<string, string> {
return true;
}
/** Return true when the id byte size is within {@link DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES}. */
function idWithinLimit(id: string): boolean {
return Buffer.byteLength(id, "utf8") <= DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES;
}
/**
* Encode one frame to a single line of JSON with a trailing newline. `JSON.stringify`
* escapes any newline inside a string value, so the returned line holds no
@@ -225,6 +245,12 @@ function validateRequest(frame: Record<string, unknown>): DuplexDecodeResult {
) {
return fail("malformed_frame", "request frame has a missing or wrong-typed field");
}
// Bound the id byte size at the protocol level. The host broker retains one id
// per distinct dispatched request, so an unbounded id is a memory-exhaustion
// path. Reject an over-limit id as a protocol error; the read path never throws.
if (!idWithinLimit(frame.id)) {
return fail("id_too_large", "request frame id exceeds the maximum size");
}
return ok(frame as unknown as DuplexRequestFrame);
}
@@ -239,6 +265,11 @@ function validateResponse(frame: Record<string, unknown>): DuplexDecodeResult {
) {
return fail("malformed_frame", "response frame has a missing or wrong-typed field");
}
// Apply the same id byte bound to the response id. The host echoes the request
// id on the response, so the same rule keeps both frame ids under one ceiling.
if (!idWithinLimit(frame.id)) {
return fail("id_too_large", "response frame id exceeds the maximum size");
}
return ok(frame as unknown as DuplexResponseFrame);
}
@@ -383,6 +383,36 @@
"error": "version_mismatch"
}
]
},
{
"name": "request-id-at-max-bytes",
"category": "idBoundValid",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"body\":\"\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "request",
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"method": "GET",
"path": "/",
"query": "",
"headers": {},
"body": ""
}
}
]
},
{
"name": "request-id-over-max-bytes",
"category": "idBoundInvalid",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"body\":\"\"}\n",
"expected": [
{
"error": "id_too_large"
}
]
}
]
}
@@ -1745,6 +1745,7 @@ export async function startSandboxCallbackBridgeServer(input: {
*/
const DUPLEX_GATEWAY_CODEC_SOURCE = `const DUPLEX_FRAME_VERSION = 1;
const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 1000000;
const DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES = 256;
const DUPLEX_NEWLINE_BYTE = 0x0a;
const DUPLEX_EMPTY = Buffer.alloc(0);
const DUPLEX_RESPONSE_OUTCOMES = new Set(["completed", "indeterminate", "unavailable"]);
@@ -1820,6 +1821,9 @@ function duplexValidateRequest(frame) {
) {
return duplexFail("malformed_frame", "request frame has a missing or wrong-typed field");
}
if (Buffer.byteLength(frame.id, "utf8") > DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES) {
return duplexFail("id_too_large", "request frame id exceeds the maximum size");
}
return duplexOk(frame);
}
@@ -1834,6 +1838,9 @@ function duplexValidateResponse(frame) {
) {
return duplexFail("malformed_frame", "response frame has a missing or wrong-typed field");
}
if (Buffer.byteLength(frame.id, "utf8") > DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES) {
return duplexFail("id_too_large", "response frame id exceeds the maximum size");
}
return duplexOk(frame);
}