Skip to content

Commit a6ac783

Browse files
committed
Add receiver-side resource limits for untrusted peers (#184)
Deserializing messages from an untrusted peer could exhaust CPU, memory, or the stack: - A long [bigint,...] digit string fed straight to BigInt() blocks the event loop, because BigInt()'s decimal parse is superlinear. - A deeply nested message could overflow the stack during evaluation. - An arbitrarily large message was JSON-parsed with no up-front size bound. Add three local, receiver-side guards in the deserialization path: - Cap bigint digit length and reject non-numeric strings before BigInt(). - Bound nesting depth in Evaluator.evaluateImpl, mirroring the existing send-side depth limit in Devaluator. - Reject oversized messages in the session read loop before JSON.parse. Limits have safe exported defaults (DEFAULT_LIMITS) and are overridable per session via a new RpcLimits-typed 'limits' field on RpcSessionOptions, surfaced to the Evaluator through the Importer interface so the standalone deserialize() path stays protected by the defaults. Limits are purely local (the protocol has no negotiation step); exceeding one throws, which aborts the session via the existing abort path.
1 parent 3d17ba1 commit a6ac783

7 files changed

Lines changed: 372 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"capnweb": patch
3+
---
4+
5+
Add receiver-side resource limits to guard against untrusted-peer resource exhaustion (#184). Deserialization now caps the length of `bigint` values (and rejects non-numeric digit strings before calling `BigInt()`), bounds message nesting depth, and rejects oversized incoming messages before parsing. The limits are local, receiver-side decisions with safe defaults (`DEFAULT_LIMITS`), and can be overridden per session via the new `limits` field on `RpcSessionOptions`. Exceeding a limit aborts the session, reusing the existing abort path.

__tests__/limits.test.ts

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright (c) 2025 Cloudflare, Inc.
2+
// Licensed under the MIT license found in the LICENSE.txt file or at:
3+
// https://opensource.org/license/mit
4+
5+
// Tests for the receive-side resource limits that guard against untrusted-peer resource
6+
// exhaustion (issue #184): bigint length cap + numeric validation, message nesting depth, and
7+
// max message size. See `RpcLimits` / `DEFAULT_LIMITS` in src/serialize.ts.
8+
9+
import { expect, it, describe } from "vitest"
10+
import { deserialize, serialize, RpcSession, RpcTransport, RpcTarget, DEFAULT_LIMITS,
11+
type RpcLimits } from "../src/index.js"
12+
13+
// Builds the wire string for a value nested inside `depth` escaped-array layers. Each escaped
14+
// array `[[ inner ]]` adds one level of recursion to the deserializer (evaluateImpl recurses into
15+
// its single element). The innermost value is the number 1.
16+
function nestedEscapedArrays(depth: number): string {
17+
let s = "1";
18+
for (let i = 0; i < depth; i++) {
19+
s = `[[${s}]]`;
20+
}
21+
return s;
22+
}
23+
24+
// A one-shot transport that feeds a single pre-baked frame to the session and then blocks, while
25+
// capturing everything the session sends back. Lets us assert exactly how the session reacts to a
26+
// hostile incoming message.
27+
class ScriptedTransport implements RpcTransport {
28+
sent: string[] = [];
29+
aborted = false;
30+
private toReceive: string[];
31+
private blockForever = new Promise<string>(() => {});
32+
33+
constructor(frames: string[]) {
34+
this.toReceive = [...frames];
35+
}
36+
37+
async send(message: string): Promise<void> {
38+
this.sent.push(message);
39+
}
40+
41+
async receive(): Promise<string> {
42+
let next = this.toReceive.shift();
43+
return next === undefined ? this.blockForever : next;
44+
}
45+
46+
abort(reason: any): void {
47+
this.aborted = true;
48+
}
49+
50+
// Resolves once the session has sent an ["abort", ...] frame, or rejects after a spin budget.
51+
async waitForAbort(): Promise<string> {
52+
for (let i = 0; i < 200; i++) {
53+
let abortFrame = this.sent.find(m => m.startsWith('["abort"'));
54+
if (abortFrame !== undefined) return abortFrame;
55+
await Promise.resolve();
56+
}
57+
throw new Error(`session did not abort; sent: ${JSON.stringify(this.sent)}`);
58+
}
59+
60+
async expectNoAbort(): Promise<void> {
61+
for (let i = 0; i < 50; i++) await Promise.resolve();
62+
let abortFrame = this.sent.find(m => m.startsWith('["abort"'));
63+
expect(abortFrame, "session unexpectedly aborted").toBeUndefined();
64+
expect(this.aborted, "transport unexpectedly aborted").toBe(false);
65+
}
66+
}
67+
68+
// Drives a server session that processes a single ["push", expr] frame, returning the transport so
69+
// the caller can inspect the reaction.
70+
function pushFrame(expr: string, limits?: Partial<RpcLimits>): ScriptedTransport {
71+
let transport = new ScriptedTransport([`["push",${expr}]`]);
72+
new RpcSession(transport, new EchoTarget(), limits ? { limits } : undefined);
73+
return transport;
74+
}
75+
76+
class EchoTarget extends RpcTarget {
77+
echo(value: unknown) { return value; }
78+
}
79+
80+
describe("bigint deserialization limits", () => {
81+
it("accepts a bigint at the default digit limit", () => {
82+
let digits = "9".repeat(DEFAULT_LIMITS.maxBigIntDigits);
83+
let value = deserialize(`["bigint","${digits}"]`);
84+
expect(value).toBe(BigInt(digits));
85+
});
86+
87+
it("rejects a bigint over the default digit limit", () => {
88+
let digits = "9".repeat(DEFAULT_LIMITS.maxBigIntDigits + 1);
89+
expect(() => deserialize(`["bigint","${digits}"]`))
90+
.toThrowError(/bigint exceeds maximum length/);
91+
});
92+
93+
it("rejects a non-numeric bigint string before calling BigInt()", () => {
94+
// These are all forms BigInt() would otherwise accept (hex/binary) or choke on; the numeric
95+
// pre-validation rejects anything that isn't an optional sign plus decimal digits.
96+
expect(() => deserialize('["bigint","0x10"]')).toThrowError(/invalid characters/);
97+
expect(() => deserialize('["bigint","12n"]')).toThrowError(/invalid characters/);
98+
expect(() => deserialize('["bigint","1 2"]')).toThrowError(/invalid characters/);
99+
expect(() => deserialize('["bigint",""]')).toThrowError(/invalid characters/);
100+
expect(() => deserialize('["bigint","abc"]')).toThrowError(/invalid characters/);
101+
});
102+
103+
it("accepts a normal negative bigint", () => {
104+
expect(deserialize('["bigint","-123"]')).toBe(-123n);
105+
});
106+
107+
it("honors a per-session override of maxBigIntDigits", async () => {
108+
let underLimit = `["bigint","${"9".repeat(4)}"]`;
109+
let overLimit = `["bigint","${"9".repeat(5)}"]`;
110+
111+
// With an override of 4 digits, a 4-digit bigint is fine but a 5-digit one aborts the session.
112+
await pushFrame(underLimit, { maxBigIntDigits: 4 }).expectNoAbort();
113+
114+
let aborted = await pushFrame(overLimit, { maxBigIntDigits: 4 }).waitForAbort();
115+
expect(aborted).toMatch(/bigint exceeds maximum length/);
116+
117+
// The same 5-digit bigint is well under the default limit, so it does NOT abort by default.
118+
await pushFrame(overLimit).expectNoAbort();
119+
});
120+
});
121+
122+
describe("message depth limits", () => {
123+
it("accepts nesting just under the default depth limit", () => {
124+
// The top value sits at depth 0, so maxDepth-1 escaped-array layers stay within the limit.
125+
let value = deserialize(nestedEscapedArrays(DEFAULT_LIMITS.maxDepth - 1));
126+
expect(value).toBeInstanceOf(Array);
127+
});
128+
129+
it("rejects nesting over the default depth limit", () => {
130+
expect(() => deserialize(nestedEscapedArrays(DEFAULT_LIMITS.maxDepth + 5)))
131+
.toThrowError(/maximum allowed message depth/);
132+
});
133+
134+
it("honors a per-session override of maxDepth", async () => {
135+
// Override depth to a small value; nesting beyond it aborts the session.
136+
let deep = nestedEscapedArrays(10);
137+
let aborted = await pushFrame(deep, { maxDepth: 4 }).waitForAbort();
138+
expect(aborted).toMatch(/maximum allowed message depth/);
139+
140+
// The same depth is well within the default, so by default it does not abort.
141+
await pushFrame(deep).expectNoAbort();
142+
});
143+
});
144+
145+
describe("message size limits", () => {
146+
it("aborts the session on a message over the size limit", async () => {
147+
// The pushed expression is a long-but-valid JSON string literal, so the resulting frame is
148+
// valid JSON and well over the 64-char override. The size check runs (and aborts) before
149+
// JSON.parse, so this exercises the size guard specifically, not a parse error.
150+
let aborted = await pushFrame(`"${"a".repeat(200)}"`, { maxMessageSize: 64 }).waitForAbort();
151+
expect(aborted).toMatch(/exceeds maximum size/);
152+
});
153+
154+
it("does not abort on a message under the size limit", async () => {
155+
await pushFrame('"small"', { maxMessageSize: 1024 }).expectNoAbort();
156+
});
157+
});
158+
159+
describe("limits backwards compatibility", () => {
160+
it("DEFAULT_LIMITS has the documented values", () => {
161+
expect(DEFAULT_LIMITS).toStrictEqual({
162+
maxBigIntDigits: 16384,
163+
maxDepth: 64,
164+
maxMessageSize: 32 * 1024 * 1024,
165+
});
166+
});
167+
168+
it("standalone deserialize round-trips normal values unchanged with no config", () => {
169+
expect(deserialize('["bigint","123"]')).toBe(123n);
170+
expect(deserialize(serialize(12345678901234567890n))).toBe(12345678901234567890n);
171+
expect(deserialize('[[[[123,456]]]]')).toStrictEqual([[123, 456]]);
172+
expect(deserialize('{"foo":{"bar":123}}')).toStrictEqual({foo: {bar: 123}});
173+
});
174+
175+
it("a session constructed with no options behaves exactly as before", async () => {
176+
// A legitimate bigint, normal nesting, and a normal call all flow through a default session
177+
// without aborting.
178+
using harness = new SessionPair(new EchoTarget());
179+
180+
let stub = harness.stub as any;
181+
expect(await stub.echo(123n)).toBe(123n);
182+
expect(await stub.echo([[1, 2, 3]])).toStrictEqual([[1, 2, 3]]);
183+
expect(await stub.echo({a: {b: {c: 1}}})).toStrictEqual({a: {b: {c: 1}}});
184+
185+
await harness.dispose();
186+
});
187+
188+
it("a session constructed with an empty options object behaves identically", async () => {
189+
using harness = new SessionPair(new EchoTarget(), {});
190+
let stub = harness.stub as any;
191+
expect(await stub.echo(999n)).toBe(999n);
192+
await harness.dispose();
193+
});
194+
});
195+
196+
// A minimal in-memory bidirectional session pair, sufficient for the round-trip checks above.
197+
class PairTransport implements RpcTransport {
198+
partner!: PairTransport;
199+
private queue: string[] = [];
200+
private waiter?: () => void;
201+
202+
async send(message: string): Promise<void> {
203+
this.partner.queue.push(message);
204+
this.partner.waiter?.();
205+
this.partner.waiter = undefined;
206+
}
207+
208+
async receive(): Promise<string> {
209+
while (this.queue.length === 0) {
210+
await new Promise<void>(resolve => { this.waiter = resolve; });
211+
}
212+
return this.queue.shift()!;
213+
}
214+
}
215+
216+
class SessionPair {
217+
private clientTransport = new PairTransport();
218+
private serverTransport = new PairTransport();
219+
private client: RpcSession;
220+
private server: RpcSession;
221+
stub: ReturnType<RpcSession["getRemoteMain"]>;
222+
223+
constructor(target: RpcTarget, options?: { limits?: Partial<RpcLimits> }) {
224+
this.clientTransport.partner = this.serverTransport;
225+
this.serverTransport.partner = this.clientTransport;
226+
this.client = new RpcSession(this.clientTransport);
227+
this.server = new RpcSession(this.serverTransport, target, options);
228+
this.stub = this.client.getRemoteMain();
229+
}
230+
231+
async dispose() {
232+
for (let i = 0; i < 16; i++) await Promise.resolve();
233+
}
234+
235+
[Symbol.dispose]() {}
236+
}

src/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { RpcTarget as RpcTargetImpl, RpcStub as RpcStubImpl, RpcPromise as RpcPromiseImpl } from "./core.js";
66
import { serialize, deserialize } from "./serialize.js";
77
import { RpcTransport, RpcSession as RpcSessionImpl, RpcSessionOptions } from "./rpc.js";
8+
import { RpcLimits, DEFAULT_LIMITS } from "./serialize.js";
89
import { RpcTargetBranded, RpcCompatible, Stub, Stubify, __RPC_TARGET_BRAND } from "./types.js";
910
import { newWebSocketRpcSession as newWebSocketRpcSessionImpl,
1011
newWorkersWebSocketRpcResponse } from "./websocket.js";
@@ -19,8 +20,8 @@ forceInitStreams();
1920

2021
// Re-export public API types.
2122
export { serialize, deserialize, newWorkersWebSocketRpcResponse, newHttpBatchRpcResponse,
22-
nodeHttpBatchRpcResponse };
23-
export type { RpcTransport, RpcSessionOptions, RpcCompatible };
23+
nodeHttpBatchRpcResponse, DEFAULT_LIMITS };
24+
export type { RpcTransport, RpcSessionOptions, RpcCompatible, RpcLimits };
2425

2526
// Hack the type system to make RpcStub's types work nicely!
2627
/**
@@ -143,17 +144,18 @@ export let newMessagePortRpcSession:<T extends RpcCompatible<T> = Empty>
143144
* credentials as parameters and returns the authorized API), then cross-origin requests should
144145
* be safe.
145146
*/
146-
export async function newWorkersRpcResponse(request: Request, localMain: any) {
147+
export async function newWorkersRpcResponse(
148+
request: Request, localMain: any, options?: RpcSessionOptions) {
147149
if (request.method === "POST") {
148-
let response = await newHttpBatchRpcResponse(request, localMain);
150+
let response = await newHttpBatchRpcResponse(request, localMain, options);
149151
// Since we're exposing the same API over WebSocket, too, and WebSocket always allows
150152
// cross-origin requests, the API necessarily must be safe for cross-origin use (e.g. because
151153
// it uses in-band authorization, as recommended in the readme). So, we might as well allow
152154
// batch requests to be made cross-origin as well.
153155
response.headers.set("Access-Control-Allow-Origin", "*");
154156
return response;
155157
} else if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
156-
return newWorkersWebSocketRpcResponse(request, localMain);
158+
return newWorkersWebSocketRpcResponse(request, localMain, options);
157159
} else {
158160
return new Response("This endpoint only accepts POST or WebSocket requests.", { status: 400 });
159161
}

src/map.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// https://opensource.org/license/mit
44

55
import { StubHook, PropertyPath, RpcPayload, RpcStub, RpcPromise, withCallInterceptor, ErrorStubHook, mapImpl, PayloadStubHook, unwrapStubAndPath, unwrapStubNoProperties } from "./core.js";
6-
import { Devaluator, Exporter, Importer, ExportId, ImportId, Evaluator } from "./serialize.js";
6+
import { Devaluator, Exporter, Importer, ExportId, ImportId, Evaluator, RpcLimits, DEFAULT_LIMITS } from "./serialize.js";
77

88
let currentMapBuilder: MapBuilder | undefined;
99

@@ -296,6 +296,14 @@ class MapApplicator implements Importer {
296296
getPipeReadable(exportId: ExportId): never {
297297
throw new Error("A mapper function cannot use pipe readables.");
298298
}
299+
300+
getLimits(): RpcLimits {
301+
// A mapper's instructions arrived inside a ["remap"] message that was itself deserialized
302+
// under the session's limits, so the default floor is sufficient protection here without
303+
// threading per-session overrides through the several layers between the session and the
304+
// mapper.
305+
return DEFAULT_LIMITS;
306+
}
299307
}
300308

301309
function applyMapToElement(input: unknown, parent: object | undefined, owner: RpcPayload | null,

src/rpc.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// https://opensource.org/license/mit
44

55
import { StubHook, RpcPayload, RpcStub, PropertyPath, PayloadStubHook, ErrorStubHook, RpcTarget, unwrapStubAndPath, streamImpl } from "./core.js";
6-
import { Devaluator, Evaluator, ExportId, ImportId, Exporter, Importer, serialize } from "./serialize.js";
6+
import { Devaluator, Evaluator, ExportId, ImportId, Exporter, Importer, serialize, RpcLimits, DEFAULT_LIMITS } from "./serialize.js";
77

88
/**
99
* Interface for an RPC transport, which is a simple bidirectional message stream. Implement this
@@ -316,6 +316,17 @@ export type RpcSessionOptions = {
316316
* to serialize the error with the stack omitted.
317317
*/
318318
onSendError?: (error: Error) => Error | void;
319+
320+
/**
321+
* Overrides for the resource limits enforced while deserializing messages from the peer. Any
322+
* field left unset falls back to `DEFAULT_LIMITS`. These guard against resource-exhaustion
323+
* attacks from untrusted peers; see `RpcLimits` for the meaning and defaults of each field.
324+
*
325+
* Limits are a purely local, receiver-side decision -- the protocol has no negotiation step, so
326+
* the peer never learns these values. A message that exceeds a limit is rejected, aborting the
327+
* session.
328+
*/
329+
limits?: Partial<RpcLimits>;
319330
};
320331

321332
class RpcSessionImpl implements Importer, Exporter {
@@ -340,8 +351,14 @@ class RpcSessionImpl implements Importer, Exporter {
340351
// may be deleted from the middle (hence leaving the array sparse).
341352
onBrokenCallbacks: ((error: any) => void)[] = [];
342353

354+
// Resource limits enforced on incoming messages, resolved once from the defaults plus any
355+
// per-session overrides.
356+
private limits: RpcLimits;
357+
343358
constructor(private transport: RpcTransport, mainHook: StubHook,
344359
private options: RpcSessionOptions) {
360+
this.limits = { ...DEFAULT_LIMITS, ...options.limits };
361+
345362
// Export zero is automatically the bootstrap object.
346363
this.exports.push({hook: mainHook, refcount: 1});
347364

@@ -534,6 +551,10 @@ class RpcSessionImpl implements Importer, Exporter {
534551
return readable;
535552
}
536553

554+
getLimits(): RpcLimits {
555+
return this.limits;
556+
}
557+
537558
createPipe(readable: ReadableStream, readableHook: StubHook): ImportId {
538559
if (this.abortReason) throw this.abortReason;
539560

@@ -746,6 +767,14 @@ class RpcSessionImpl implements Importer, Exporter {
746767
}
747768
}
748769

770+
// Bound the up-front allocation of a single message before parsing it. This is a local,
771+
// receiver-side guard against an untrusted peer sending an enormous message; a throw here
772+
// propagates out of readLoop and aborts the session.
773+
if (msgText.length > this.limits.maxMessageSize) {
774+
throw new TypeError(
775+
`Incoming message exceeds maximum size of ${this.limits.maxMessageSize} characters.`);
776+
}
777+
749778
let msg = JSON.parse(msgText);
750779
if (this.abortReason) break; // check again before processing
751780

0 commit comments

Comments
 (0)