diff --git a/packages/adapter-utils/src/duplex-bridge-broker.test.ts b/packages/adapter-utils/src/duplex-bridge-broker.test.ts index c0d11e42d7d..cf00f6ebb7a 100644 --- a/packages/adapter-utils/src/duplex-bridge-broker.test.ts +++ b/packages/adapter-utils/src/duplex-bridge-broker.test.ts @@ -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"); + }); }); diff --git a/packages/adapter-utils/src/duplex-bridge-broker.ts b/packages/adapter-utils/src/duplex-bridge-broker.ts index 3263470cb5f..c51da96b9b6 100644 --- a/packages/adapter-utils/src/duplex-bridge-broker.ts +++ b/packages/adapter-utils/src/duplex-bridge-broker.ts @@ -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 diff --git a/packages/adapter-utils/src/duplex-frame-codec.test.ts b/packages/adapter-utils/src/duplex-frame-codec.test.ts index c0c254b51d0..e9c00b0b75d 100644 --- a/packages/adapter-utils/src/duplex-frame-codec.test.ts +++ b/packages/adapter-utils/src/duplex-frame-codec.test.ts @@ -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"); + }); +}); diff --git a/packages/adapter-utils/src/duplex-frame-codec.ts b/packages/adapter-utils/src/duplex-frame-codec.ts index 056c86641a0..2efdb384a23 100644 --- a/packages/adapter-utils/src/duplex-frame-codec.ts +++ b/packages/adapter-utils/src/duplex-frame-codec.ts @@ -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 { 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): 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): 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); } diff --git a/packages/adapter-utils/src/duplex-frame-vectors.json b/packages/adapter-utils/src/duplex-frame-vectors.json index d3a1df298b6..74ded507fad 100644 --- a/packages/adapter-utils/src/duplex-frame-vectors.json +++ b/packages/adapter-utils/src/duplex-frame-vectors.json @@ -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" + } + ] } ] } diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 1f807e9fc04..fc5601708aa 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -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); }