Skip to content

Security Considerations

Authentication over WebSocket, denial-of-service from pipelining, payload limits, and why types are not validation.

Updated View as Markdown

Cap’n Web is an object-capability system, which gives you strong tools for authorization, but there are a handful of things you must get right yourself.

Authenticate in-band, not with cookies

The WebSocket API in browsers always permits cross-site connections, and does not permit setting headers. Because of this, you generally cannot use cookies nor other headers for authentication.

Instead, we highly recommend authenticating in-band, via an RPC method that returns the authenticated API:

interface PublicApi {
  // Authenticate the API token, and return the authenticated API.
  authenticate(apiToken: string): AuthedApi;

  // Doesn't require authentication.
  getUserProfile(userId: string): Promise<UserProfile>;
}
// The client never gets an AuthedApi without presenting a valid token.
using api = newWebSocketRpcSession<PublicApi>('wss://example.com/api');
using authed = api.authenticate(apiToken);

On the server, authenticate() checks the credential once and returns a new object holding the result:

class PublicApi extends RpcTarget {
  authenticate(apiToken: string): AuthedApi {
    let user = verifyToken(apiToken);    // throws if invalid
    return new AuthedApi(user);
  }
}

class AuthedApi extends RpcTarget {
  constructor(private user: User) { super(); }

  // No token, no re-check. Holding this object is the proof.
  getUserId() { return this.user.id; }
}

This is the object-capability pattern doing real work: the returned AuthedApi stub is the authorization. There is no ambient authority to confuse, and no way to call an authenticated method without holding the capability. Thanks to pipelining, it also costs no extra round trip.

Yes, this means the server holds state, but only in memory, and only for the lifetime of that one session: the WebSocket connection, or the single HTTP batch. Nothing is persisted, and there is no session store to secure or expire. See Sessions & reconnection.

Rate-limit, because pipelining is cheap for attackers

Cap’n Web’s pipelining can make it easy for a malicious client to enqueue a large amount of work to occur on a server, in a single message.

To mitigate this, implement rate limits on expensive operations. Note that limits applied by a load balancer or gateway will not help here: they count requests or frames, and pipelining makes one frame arbitrarily expensive. The limit has to live in the application.

Two amplifiers worth knowing about:

  • Nested .map() multiplies. A map over N elements whose callback maps over M produces N × M server-side calls from one client message. Unbounded recursion is less dangerous than it looks: the recording is built on the caller’s stack, so a runaway callback overflows the client first. But a deliberately crafted deep recording is not self-limiting.

  • Un-awaited calls accumulate. Every outstanding promise and every stub the peer holds pins an entry in your export table, and the object behind it, for the life of the session. A peer that never settles or disposes anything grows your memory monotonically.

    There is no library setting for this. RpcSessionOptions.limits covers message size, nesting depth and bigint digits (not reference counts), so if you need a bound on how much one session can pin, you have to enforce it in your own code. Attaching disposers to the objects you return gives you something to count.

If using Cloudflare Workers, also consider configuring per-request CPU limits to be lower than the default 30s. Note that in stateless Workers (that is, not Durable Objects), the system considers an entire WebSocket session to be one “request” for CPU limit purposes.

Set transport payload limits

Cap’n Web applies receiver-side resource limits before expensive message processing, including a maximum incoming message size before JSON.parse.

If your app is exposed to untrusted peers, also configure native transport or socket payload limits where available:

Runtime Option
Node.js ws new WebSocketServer({ maxPayload })
Bun Bun.serve({ websocket: { maxPayloadLength } })
Browsers / others The runtime’s built-in WebSocket cap

Cap’n Web’s own check runs after RpcTransport.receive() has returned a complete message string, so transport-level limits are still the first line of defence against buffering very large frames.

Types are not validation

Cap’n Web currently does not provide any runtime type checking. When using TypeScript, keep in mind that types are checked only at compile time. A malicious client can send types you did not expect, and this could cause your application to behave in unexpected ways.

For example, MongoDB uses special property names to express queries; placing attacker-provided values directly into queries can result in query injection vulnerabilities, similar to SQL injection. Of course, JSON has always had the same problem, and there exists tooling to solve it.

Can a peer pass a callback where you declared a string? Yes. It will arrive as an RpcStub, your code will do something surprising with it, and TypeScript will have told you nothing. Validate at the boundary.

Use capnweb-validate

The companion package capnweb-validate is the recommended answer, and it is built for exactly this problem. It keeps your TypeScript method signatures as the source of truth and generates the runtime checks from them at build time, so the boundary is described once rather than twice:

import { validateRpc } from 'capnweb-validate';

@validateRpc()
export class Api extends RpcTarget {
  // Arguments are checked against these types before the method body runs.
  getUser(id: string, opts: { includeEmail: boolean }) {
    // ...
  }
}

Two properties make it worth reaching for over hand-written checks:

  • Every method is covered. Validation is applied to the class, so a method added next year is checked without anyone remembering to check it. Hand-rolled guards protect only the boundary someone thought about.
  • It fails closed. If the decorator is left untransformed because the bundler plugin is not wired up, it throws a configuration error at startup rather than quietly running unvalidated. You cannot ship a service that only looks validated.

See Runtime Validation for setup.

A general-purpose schema library such as Zod works too, and is the better choice if you already validate with one elsewhere in the codebase, or if you need constraints a type cannot express, like “a string of at most 200 characters” or “a positive integer”. The two compose: derive the shape from the types, then apply your own checks to the values. In the future we hope to explore auto-generating type-checking code based on TypeScript types in the core library.

What the protocol does guarantee

Type confusion is your problem, but prototype pollution is not. The protocol hardens two things regardless of what you do:

  • Object.prototype members are unreachable. Any property name that exists on Object.prototype (constructor, __proto__, valueOf, hasOwnProperty and friends) is blocked both when resolving a property path and when deserializing an object literal. This holds even if the target object has legitimately overridden the name.
  • toJSON is stripped on the way in. It is not an Object.prototype member, but it would let a peer influence how your values serialize, so an incoming object carrying one has it removed. Note that this applies to deserialization only, not to property paths.
  • Array paths accept only non-negative integer indices, matching what serialization can produce.

What is reachable on an object depends on what kind of object it is, and the two rules are opposites: an RpcTarget exposes its prototype members and explicitly refuses instance properties, while a plain object exposes its own properties only. That distinction decides where it is safe to put a secret, so read RpcTarget rather than guessing.

private and .map()

private is not private. TypeScript’s private is erased at runtime and does not hide a method from RPC. Use #-prefixed names for genuinely private members. See RpcTarget.

Stubs captured by .map() are handed to the peer. Any stubs you use in a .map() callback, and any parameters you pass to them, are sent to the peer, and a malicious peer can use them for anything, not just calling your callback. Typically it only makes sense to invoke stubs that came from that same peer originally. See The magic map().

Reporting vulnerabilities

Please report security issues in Cap’n Web according to the project’s security policy.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close