Cross-Site WebSocket Hijacking: CSRF on a Connection That Talks Back
A WebSocket handshake carries the user's cookies but faces no CORS check. When the server skips Origin validation, any page the user visits can open an authenticated socket, read what it pushes and send commands back. Here is how to test for it and how to close it.
The victim's browser, which opens the socket and attaches the session cookie to the handshake
Required condition
The server authenticates the WebSocket handshake with cookies alone and accepts an Origin it does not serve.
Potential impact
A third-party page reads what the socket pushes and sends commands as the victim while the page stays open.
Primary defense
An exact-match Origin allowlist on every upgrade, plus a per-connection ticket the front end obtains from its own origin.
Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.
On this page
Cross-site WebSocket hijacking (CSWSH) is cross-site request forgery aimed at the WebSocket handshake. The handshake is an ordinary HTTP request, so the browser attaches the user's cookies to it. Unlike fetch, though, the WebSocket API has no CORS step: no preflight and no Access-Control-Allow-Origin decision. The server's own check of the Origin header is the only gate.
Without that check, the result is worse than classic CSRF. A forged form is write-only because the same-origin policy hides the response. A hijacked socket is two-way: the attacker's page receives what the server pushes and sends messages back while the victim keeps the tab open. SelfSec reports the missing check as CWE-1385, Missing Origin Validation in WebSockets, at high severity under OWASP A01:2025 Broken Access Control.
How it happens
A typical Node.js server authenticates the socket from the session cookie and never asks where the handshake came from:
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
const user = sessions.fromCookie(req.headers.cookie);
if (!user) return ws.close(1008, 'login required');
inbox.subscribe(user.id, (msg) => ws.send(JSON.stringify(msg)));
ws.on('message', (data) => commands.run(user, JSON.parse(data)));
});
The cookie proves that a signed-in browser made the request, not that the application's own front end opened the socket. The Origin header does: the browser sets it to the origin of the page that called new WebSocket(), and page script cannot change it. RFC 6455 leaves it to the server to check that value and refuse an unwanted handshake, for example with 403 Forbidden. This server never reads it.
The gap hides well: forms carry anti-CSRF tokens and the API has a strict CORS policy, but neither covers the socket.
A concrete test: open the socket from another origin
An authorized tester needs a test account and a page on an origin they control, such as https://tester.example. Sign in, filter the browser's network panel to WebSocket traffic, and note the socket URL and whether the client's first frames carry a token. Then replay the handshake through an intercepting proxy with only the Origin changed:
A 403 means the server checks the origin; 101 Switching Protocols means it accepted a site it does not serve. The test page then opens the socket with the browser's own cookies and only listens:
Load it in the browser signed in to the test account. If the console shows that account's data, any site can do the same; close the tab without sending commands. If only the proxy replay succeeded, check the session cookie: SameSite=Lax or Strict keeps it off a handshake started from another site, which narrows the attack without closing it.
What an attacker gains
Live data: messages, notifications, balances or tokens pushed to the signed-in user.
Actions as the victim: any command the front end sends over the socket, such as posting, transferring funds or changing settings.
Persistence: the connection keeps working while the tab stays open.
Reading a SelfSec finding
After the crawl, SelfSec gathers the ws:// and wss:// URLs it saw: sockets the rendered pages opened, WebSocket links and endpoints found in JavaScript. For up to 25 distinct URLs it replays only the upgrade handshake with Origin: https://attacker.example, sends no application message and drops the connection once it opens. A completed upgrade is reported as Cross-Site WebSocket Hijacking (Origin not enforced), with the forged origin as evidence.
That handshake carries no session cookie and there is no separate confirmation pass, so the finding stays possible: it proves the server accepted a foreign Origin, and the browser test above settles the impact. A public socket, or one waiting for a token a cross-site page cannot read, lowers the risk, but the origin check is still missing. The missing cookie also cuts the other way: a socket that refuses handshakes without a session is never reported, even when it ignores Origin, so run the browser test on every cookie-authenticated socket.
The fix: check the origin, then authenticate the connection
An exact-match Origin allowlist stops other sites at the handshake. A per-connection ticket removes the reliance on ambient cookies, so a page that slips past the origin check has nothing to authenticate with. With the Node.js ws library, handle the HTTP server's upgrade event so the check runs before any socket exists:
The front end gets a random, single-use ticket that expires within seconds from a POST /ws/ticket on its own origin, protected like any other state-changing request, and sends it as the first message. A cross-site page can trigger that request but, under a strict CORS policy, cannot read the ticket. The OWASP cheat sheet prefers the first message to the query string, which lands in access logs.
In ASP.NET Core, the WebSockets middleware answers any other Origin with 403 before your endpoint runs:
var webSocketOptions = new WebSocketOptions();
webSocketOptions.AllowedOrigins.Add("https://app.example");
app.UseWebSockets(webSocketOptions);
An empty list, the default, allows every origin. A handshake with no Origin passes, which is acceptable because browsers always send one and other clients do not hold the victim's cookies. In every stack, add the ticket, authorize each state-changing message against the current session, and set SameSite=Lax or Strict on the session cookie as a second layer.
Fixes that do not hold
A strict CORS policy: browsers do not apply CORS to WebSocket handshakes. See CORS misconfiguration.
CSRF middleware that skips safe methods: the handshake is a GET, so anti-CSRF filters and Go's http.CrossOriginProtection let it through.
Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.