Realtime transport
Published
A WebSocket upgrade is not subject to the same-origin policy, and the browser attaches cookies to it anyway. That combination means the origin check on a WebSocket server is not a defence in depth - it is the defence. It is also an optional function-valued property on an options object, and the server's default is not to have one. We protected a file that configures a WebSocket server, renamed the option names a group at a time, and counted the handshakes.
What the sample actually does
The file configures a WebSocket server with six options and puts four upgrade requests through it, arranged so that each configured guard refuses exactly one of them. A first-party page is accepted. A page hosted on an attacker's site is refused by the origin allowlist, with the victim's session cookie attached to the request because that is what a browser does. A client with no origin header at all - a script or a native application - is refused because it is not on the allowlist. An upgrade aimed at a path this server does not serve is refused by the path check.
Three frames then arrive on the accepted socket: an ordinary message, one exactly at the configured cap, and one far past it. The sample prints what the server buffered and whether it closed the connection.
The server is copied in unprotected. Its defaults are the real ones this family of libraries ships: no origin verification at all, a per-message cap of 100 MiB, no path restriction, connection tracking on, UTF-8 validation on and compression off.
Protection alone was applied first on five presets and all five behaved identically to the unprotected file.
The origin check stopped existing
Renaming verifyClient is the sharpest result in the area and there is nothing subtle about the mechanism: the option holds a function, the server cannot find it, and a server with no verification function accepts every origin. That is the documented default, and it is the right default for a library that cannot know what your origins are.
The measured effect: the attacker-origin handshake went from refused to accepted, and the sample's cross-site-socket-opened assertion went from false to true while cross-site-had-cookies stayed true. Accepted handshakes went from one in four to three in four. The reason the server reported for accepting the first-party connection changed from origin-allowed to no-origin-check-configured, which is the only visible trace anywhere.
It is worth being explicit about what an open socket buys an attacker, because a WebSocket is not a fetch. The browser sends the session cookie with the upgrade, there is no preflight, and the same-origin policy does not apply to the connection. Once the socket is open the attacker's page can send whatever the protocol accepts and read whatever the server pushes - for the duration of the session, in both directions, as the authenticated user. Anything your realtime protocol can do, it can now do from a page the user merely visited.
The application looks completely healthy on this arm. The first-party client connects, messages flow, the health check passes. The only difference is that a second population of clients is also connecting.
The per-message cap reverted to 100 MiB
Renaming maxPayload took the configured 64 KB cap back to the library default of 104,857,600 bytes. The oversized frame in the sample went from being refused with close code 1009 - the code that means the message was too big - to being accepted, and the buffer went from 64 KB to 102,400 KB. The connection was not closed.
The arithmetic is the whole point. A cap is a per-message, per-connection allowance, so the memory a server can be made to hold is that number multiplied by the number of sockets an attacker can open. Sixty-four kilobytes across a thousand connections is 64 MB. A hundred mebibytes across the same thousand connections is not a number a process survives.
It combines badly with the previous result, and in this measurement the combination is one rename apart. A server with no origin check accepts connections from anywhere; a server with the default payload cap lets each of them buffer a hundred mebibytes.
Renaming path is the mild version of the same class: the server stops restricting which path it answers on, and the upgrade aimed at the wrong path went from refused to accepted. That matters mainly to whatever sits in front of the server making routing decisions on the same path.
Three controls, all inert, and one that inverts
Three options in this configuration are pinned to values identical to the library defaults - connection tracking on, UTF-8 validation on, compression off - and renaming each of them changed nothing at all, on both output targets. That is the predicted result and it held exactly, for the seventh consecutive pass.
The caveat is the one this series keeps attaching to inert controls, and here it is unusually sharp. UTF-8 validation is one of those switches an application sometimes turns off deliberately for throughput. In a deployment that had done so, this same rename would turn validation back on and start rejecting frames that used to be accepted - a change from permissive to strict, which is the loud direction. The safety of an inert arm belongs to the value you happened to pin, not to the option.
One arm inverted the usual direction outright. Renaming the origin property on the upgrade request - the field the allowlist function compares - made the first-party handshake fail: accepted connections went from one in four to zero in four. The allowlist read undefined, undefined is not on the list, and the server refused everybody. A comparison against an allowlist fails closed on a missing value, which is exactly the property you want and exactly the property a comparison against a denylist does not have.
Reading the socket's own verdict
Renaming the fields of the result the server hands back is the quiet direction, and this area produced a clean example of what it costs.
On that arm the socket behaved identically: the oversized frame was still refused at the cap, the origin check still ran, nothing about the server changed. What changed is what the application could see. The assertion that the frame was closed read undefined, the close code read undefined, and the buffered-megabytes figure came out as NaN.
That is not a security failure, it is a reconciliation failure, and it is the shape that surfaces weeks later. Metrics that count 1009 closures flatline, so a dashboard shows a problem disappearing rather than a measurement disappearing. Any alert threshold expressed as a comparison against a number is now comparing against NaN, and every comparison against NaN is false, so nothing ever fires.
The other in-direction arm, renaming the byte count on the frames the sample hands the server, made every size comparison undefined and therefore false: the oversized frame was accepted and the buffered figure printed as NaN. A guard written as if (size > max) reject() fails open on a missing value. The same guard written as if (!(size <= max)) reject() fails closed on the identical input, which is a one-character difference with opposite consequences.
What to do about it
None of this is a defect in the obfuscator and none of it is specific to one WebSocket library. Member renaming rewrites property names in the code it is given; an installed server reads fixed names, and a name it cannot find means the documented default applies. WebSocket servers are unusually exposed to this because their defaults assume a caller who will configure them, and because the single most important control - the origin check - is not a flag but a function you supply.
Scope the renaming so the server options object sits outside the MemberRegexp. If you prefer not to maintain an exclusion list, do the origin check inside your own upgrade handler instead of handing the library a function, so the decision lives in code rather than in a property name.
For verification, the assertion that catches the worst arm takes one integration test: complete an upgrade with an Origin header your allowlist does not contain and require the handshake to fail. Add a second that sends a frame one byte over your cap and requires close code 1009. Both run against the protected artifact in a few seconds, and both fail on the arms measured here while a connectivity smoke test passes on every one of them.
Frequently asked questions
Does protecting my JavaScript break a WebSocket server on its own?
Not in this measurement. Protection alone was applied on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming aimed at the option names.
Why is an origin check so important for WebSockets specifically?
Because the same-origin policy does not apply to a WebSocket upgrade and the browser attaches cookies to it anyway. There is no preflight and no CORS negotiation, so the server's own origin check is the control rather than a backup for one.
What can an attacker do with an open socket?
Whatever your protocol allows, as the authenticated user, in both directions, for as long as the session lasts. That is why the handshake is the place to stop it rather than the message handler.
Is the 100 MiB default payload cap really the library default?
Yes, in this family of servers. It is a sensible ceiling for a library that cannot know your workload, and it is why applications pin a much smaller value. Losing that pin returns you to the ceiling, per message and per connection.
Three of our options match the library defaults. Are they safe to rename?
They measured completely inert, and that safety belongs to the value you pinned rather than to the option. An application that had deliberately turned UTF-8 validation off for throughput would find the same rename turning it back on and rejecting frames that used to be accepted.
Why did renaming the origin field break the legitimate client?
Because an allowlist comparison against a missing value fails closed. The list did not contain undefined, so every connection was refused. A denylist comparison on the same input would have failed open, which is the argument for allowlists in one line.
What is the cheapest test?
Attempt an upgrade with a disallowed Origin header and require failure, and send a frame one byte over your cap and require close code 1009. Run both against the protected build. A connectivity smoke test passes on every arm measured in this article.
Related reading