Skip to content

Add receiver-side resource limits for untrusted peers (#184)#185

Open
ndisidore wants to merge 1 commit into
cloudflare:mainfrom
ndisidore:fix/184-untrusted-resource-limits
Open

Add receiver-side resource limits for untrusted peers (#184)#185
ndisidore wants to merge 1 commit into
cloudflare:mainfrom
ndisidore:fix/184-untrusted-resource-limits

Conversation

@ndisidore
Copy link
Copy Markdown

Summary

Deserializing untrusted input could exhaust a peer's resources. Most concretely (#184), a ["bigint", "<digits>"] wire value was handed straight to BigInt() with no length check, and BigInt() decimal parsing is superlinear — so a multi-megabyte digit string blocks the event loop. While fixing that, the receive path is also bounded against the related single-message exhaustion vectors the issue calls out ("review that we're properly limiting message sizes overall").

The guards are local, receiver-side decisions that fit capnweb's existing design: no new dependencies, no protocol changes, and reuse of the existing abort path on violation.

What this changes

  • bigint length cap + numeric validation — reject digit strings longer than the limit, and reject anything that isn't an optional sign followed by ASCII decimal digits, before calling BigInt().
  • Message nesting depth guardevaluateImpl now tracks recursion depth and rejects messages nested beyond the limit (mirroring the existing send-side depth cap), so a tiny deeply-nested message can't overflow the stack.
  • Maximum message size — incoming messages larger than the limit are rejected before JSON.parse, bounding the up-front allocation.

Configuration

Limits are always applied from the exported DEFAULT_LIMITS, and can be overridden per session via a new optional limits field on RpcSessionOptions:

new RpcSession(transport, target, {
  limits: { maxBigIntDigits: 4096, maxDepth: 32, maxMessageSize: 8 * 1024 * 1024 },
});

Defaults:

  • maxBigIntDigits: 16384 — 2^14 digits ≈ ~54,000 bits, far beyond any practical cryptographic integer (an RSA-16384 modulus is ~4,933 decimal digits), so no legitimate value is rejected — yet orders of magnitude below the millions of digits needed for BigInt()'s superlinear parse to block meaningfully.
  • maxDepth: 64 — matches the existing send-side depth limit, so capnweb never rejects a message it would itself send.
  • maxMessageSize: 32 MiB — generous for legitimate batched and base64/blob payloads while still bounding a single allocation.

Because the protocol has no negotiation step and a limit is a purely local receiver-side decision, the defaults are deliberately generous: a sender cannot discover the receiver's limit, so the defaults must never trip legitimate traffic. They are tunable per session for endpoints with unusual needs.

Behavior on violation

Exceeding any limit throws, which flows through the existing readLoop → abort() path: the session sends an ["abort", ...] frame and tears down. The thrown errors name the limit and its value, so a cooperative peer sees a descriptive reason on the aborted session — riding the abort frame that is already sent, with no protocol change.

Alternatives considered

  • Hardcoded constants with no override — simplest, and still covers the standalone deserialize() path, but leaves no escape hatch for applications that legitimately exchange very large payloads. Kept the hardcoded defaults as the floor, but made them overridable.
  • Global / static configuration — rejected: there is no precedent in the codebase (configuration is per-session), it would leak one application's setting across unrelated sessions, and it is awkward inside Workers isolates.
  • Protocol handshake to advertise limits to the peer — rejected: it adds a round trip (or is structurally impossible for the one-shot HTTP batch transport), undermines promise pipelining by forcing an await before the first call, and adds no security — an untrusted peer simply ignores advertised limits, so the receiver must enforce locally regardless.
  • App-level getLimits() RPC method — purely a cooperative-peer ergonomic that needs no library change (an application can already expose such a method); it is not a substitute for local enforcement, so it is left to applications that want it.

Deferred (potential follow-ups)

These are larger resource-exhaustion concerns from the issue discussion that need rate/quota or backpressure design rather than a per-message guard, and are intentionally out of scope here:

  • Unbounded growth of the imports/exports tables from a flood of push / stream / pipe messages (the "many messages → OOM" case) — needs per-session quotas and backpressure.
  • Attacker-chosen import indices causing sparse-array bloat.
  • remap instruction/capture fan-out producing multiplicative work.
  • Streams and blobs buffered fully in memory.

Note: nested call-arguments are deserialized as a fresh payload with a fresh depth budget, so a message that nests deeply through call arguments can still recurse beyond maxDepth. This fails safe — it is bounded by maxMessageSize and produces a clean session abort (a caught RangeError), not a process crash (verified) — and belongs to the same follow-up bucket.

Testing

  • New __tests__/limits.test.ts (14 tests): just-under / over boundaries for each limit, non-numeric bigint rejection, per-session override enforcement, the standalone deserialize() protected by defaults, and backwards-compatibility — sessions constructed with no options, and with empty options, round-trip normal bigints / nesting / calls unchanged.
  • Full suite green: node 156 passed, workerd 145 passed, no regressions.

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.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 30, 2026

🦋 Changeset detected

Latest commit: a6ac783

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
capnweb Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 30, 2026

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@185

commit: a6ac783

@ndisidore
Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Jun 1, 2026
Comment thread src/serialize.ts
throw new TypeError(
`Deserialized bigint exceeds maximum length of ${maxBigIntDigits} digits.`);
}
// Reject anything BigInt() would parse expensively or unexpectedly before handing it
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

To be discussed:
I think this was a bug/unintentional behavior before.

protocol.md:181: "A bigint value, represented as a decimal string."

It is technically possible now to handcraft ["bigint","0xff"] which unconditionally calls BigInt(value[1]) meaning this gets parsed as 255n.
But this means there is a disconnect in the protocol writeup and the actual implementation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant