Skip to content

Security ​

wRPC's defaults are convenient for local development and deliberately loud about what has to change before production. This page collects the whole picture in one place: what is on by default, what you must configure, and which attacks the library already refuses on your behalf.

Reporting a vulnerability

See SECURITY.md.

Before production: the short list ​

Do thisWhy
Set cors.originsWithout it the WebSocket upgrade accepts any origin — and browsers do not apply the same-origin policy to WebSockets.
Set cors.credentials: true alongside itA wildcard origin cannot carry the session cookie.
Set sessions.cookie.secure / sameSiteDefaults suit http://localhost, not the public internet.
Replace MemorySessionStoreIt is a bounded in-process map; sessions vanish on restart and do not span instances.
Decide on introspectiontrue publishes your method list to anyone. 'session' or false narrows it.
Lower maxCalls / maxSubscriptions / maxBatch if a connection is untrustedThe defaults are generous.

The trust boundary ​

Everything a peer sends is untrusted input: packet fields, procedure names, args, cookies, Last-Event-ID, channel ids, and — with trace context — even traceparent. Everything below is about keeping that input from becoming something else.

Connection metadata ​

Declared headers and meta are peer-controlled labels — never authorization inputs. wRPC holds that line structurally: both bags are size-capped on the encoded input (metaMaxBytes), plain-object-checked, __proto__-stripped and frozen; the ws query path cannot spoof an observed header (observed always wins the merge, and reserved names — cookie, host, origin, the forwarding and client-ip spellings, sec-*, content-*, proxy-*, x-wrpc-*, and the exact namespaces identity-aware proxies set about the user they authenticated (x-auth-request-*, x-amzn-oidc-*, x-goog-iap-*, x-ms-client-principal*, remote-user) — are dropped from it outright; declaredHeaders: [...] allowlists the rest, and context.meta.declared names what came from a declaration); and every violation is a refusal that leaves the connection unlabelled, never an error that leaks parsing internals. The one rule the framework cannot enforce for you: never branch an access decision on client.meta — that is the session's job.

Access control ​

access: 'session' is the built-in gate: no session, 403, before the handler runs. It is coarse on purpose. Anything finer — roles, tenancy, ownership — belongs in a hook, where a throw ends the call with your code:

js
const requireRole = (role) => async (context) => {
  if (!context.session?.state.roles?.includes(role)) {
    const error = new Error('Forbidden');
    error.code = 403;
    throw error;
  }
};

system/introspect is a procedure like any other, so router-level hooks apply to it too — which is the third option beyond introspection: 'session' | false.

The cross-site GET gate ​

A SameSite=Lax cookie rides along on cross-site navigation. Convenient for web pages, dangerous for an RPC endpoint: an attacker's page could point a browser at {basePath}/account/deleteEverything and the cookie would come along.

So in REST mode a GET/HEAD only gets its cookie-restored session when the request proves same-origin intent through Sec-Fetch-Site. A cross-site one runs anonymously — public procedures only, 403 for the rest. Packet-mode POSTs are unaffected, and non-browser peers that send no Fetch metadata keep their session.

Origin, on the upgrade ​

CORS in a browser is a courtesy the browser extends. On the WebSocket upgrade it is a real defence, because browsers do not apply the same-origin policy to WebSockets: without an allowlist, any page anywhere can open a socket to your server with the user's cookies attached. Configure cors.origins and the default verifyClient refuses a mismatched Origin outright. See Server → CORS.

Prototype pollution ​

Peer-controlled strings become object keys in three places, and each one is defended rather than trusted:

  • Session state. context.session.state.x = v goes through a proxy that uses Object.defineProperty, not assignment — so state['__proto__'] = x stores a value instead of swapping the object's prototype.
  • Unit and method names. assignKey in the router applies the same rule at definition time.
  • Query parameters. REST mode parses them with Object.fromEntries(new URLSearchParams(...)), which uses CreateDataProperty — so ?__proto__=x lands as an own property. A hand-rolled obj[key] = value loop here would reintroduce the hole, which is why that allocation is deliberately not optimized away.
  • Declared metadata. JSON.parse defines "__proto__" as an own data property and pollutes nothing by itself — but application code that spreads or Object.assigns client.meta bags into a config would carry the key along, so wRPC drops it during sanitizing, and both bags are null-prototyped and frozen besides.

The same reasoning covers the dispatch tables: any table keyed on peer-controlled input uses Object.hasOwn or a null prototype, so TABLE['toString'] cannot answer with an inherited function that quietly swallows a packet.

Resource limits ​

Every limit below exists because the alternative is memory or CPU a peer can spend on your behalf.

LimitDefaultWhat it bounds
maxCalls1000In-flight calls per connection — each holds a controller, a context and possibly a queue slot.
maxSubscriptions256Live subscriptions per connection.
maxBatch128Packets in one batch frame.
maxPayload16 MiBLargest inbound WebSocket message.
maxBodySize10 MiBLargest HTTP body.
maxBuffer / maxBackpressureengineOutbound buffering before a socket is dropped.
queue: { concurrency, size }per procedureWork admitted to one handler; overflow answers 503.
timeoutper procedureA handler that never returns answers 408.

Over-limit paths answer with a code and stay up: 429 for too many calls, 503 for a full queue or a draining server, 408 for a timeout. They are errors, not disconnects.

Compression bombs ​

permessage-deflate inflates on the receive path, where a small frame can expand into a large buffer. Decompression is therefore capped by maxOutputLength — a frame that would exceed it fails instead of allocating. Enable deflate when bandwidth is the constraint; see the cost on fan-out before turning it on everywhere.

SSE channels ​

The SSE transport keeps server-side state per channel, so it is bounded on three axes: maxChannels (10,000), maxChannelsPerAddress (100 — answering 429), and replayBytes (1 MiB of retained frames per channel). A channel whose stream dropped is held only for retention (30 s). Behind a load balancer the per-address axis counts the balancer's one address — inject sse: { clientAddress } or disable that axis; see SSE.

Channel and session binding ​

The server mints the SSE channel id and hands it out once, in the ready frame. The channel is bound to the cookie identity of the GET that created it, and every re-attach and POST must present the same one — a request naming a live id without it is refused with 403. So a leaked id (URLs end up in logs and referrers) is not a bearer token for that channel's session. An id the server no longer holds answers 409, and the built-in transport reacts by starting a fresh channel.

Session cookies are built against the RFC 6265 grammar, and a name or value outside it throws rather than being emitted — a token from an injected generateToken cannot smuggle attributes or a second cookie into the Set-Cookie line.

Error messages do not leak ​

A 4xx is part of the protocol conversation and travels with its message: the peer sent something wrong and deserves to know what. A 5xx is a server internal, so its message is replaced with the generic status text on the wire and the real one goes to the log — unless the error explicitly opts in with error.expose = true.

js
const error = new Error('Rate limited: 100/min');
error.code = 429;   // 4xx — the message travels
throw error;

Stack traces never travel. See the error reference for the full code table.

Subprotocol negotiation ​

The client offers the protocol revisions it speaks — wrpc.v2, wrpc.v1 — and the server selects the newest; a 1.0 peer on either end settles the connection on wrpc.v1, where bytes travel as 1.0's JSON. A server can require a revision through protocols / handleProtocols on the engine, where returning false rejects the handshake — useful when a shared listener must tell wRPC clients apart from everything else pointed at the same port.

The same header also carries data: a browser client rides its declared headers/meta and a Bearer credential as wrpc.h., wrpc.m. and wrpc.bearer. offers (why). Those tokens are taken out of offered before your handleProtocols runs and out of context.meta.headers, and an engine never echoes one — a selector like (offered) => offered.at(-1) cannot reflect a credential into the response. They remain on the raw req.headers, so keep that header out of access logs the way you would Authorization.

What wRPC does not do for you ​

  • It is not a firewall. Rate limiting beyond the per-connection caps is an onRequest hook plus your own bucket — see Rate limiting & throttling.
  • It does not authenticate. startSession trusts whatever your login handler verified.
  • It does not encrypt by default. Run protocol: 'https', or terminate TLS at a proxy and run 'http' behind it — that comes first, always. Where TLS ends before the data does (a Redis backplane, a broker's log, a shared session store, a TLS terminator you do not run, a relay that should not read what it relays), Encryption is the opt-in layer.
  • It does not sanitize your data. input/output validators are yours to write; wRPC only runs them.

Released under the MIT License.