Skip to content
Cross-Site Request ForgeryWebSocket Hijacking

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.

Part 2 of 2From the Cross-Site Request Forgery series

At a glance

CWE-1385
Interpreter
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:

GET /ws HTTP/1.1
Host: app.example
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: https://tester.example
Cookie: session=4c1d9a0e

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:

const ws = new WebSocket('wss://app.example/ws');
ws.addEventListener('open', () => console.log('socket open'));
ws.addEventListener('message', (event) => console.log('received', event.data));

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:

import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';

const allowedOrigins = new Set(['https://app.example']);
const server = createServer(app);
const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', (req, socket, head) => {
  if (!allowedOrigins.has(req.headers.origin)) {
    socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
    socket.destroy();
    return;
  }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});

wss.on('connection', (ws) => {
  const timer = setTimeout(() => ws.close(1008, 'authentication required'), 5000);
  ws.once('message', async (data) => {
    clearTimeout(timer);
    const user = await tickets.redeem(data.toString());
    if (!user) return ws.close(1008, 'authentication failed');
    attachSession(ws, user);
  });
});

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.
  • Pattern matching: origin.includes('app.example') accepts https://app.example.attacker.example, and endsWith('app.example') accepts https://notapp.example. Compare whole origins.
  • Allowing null: sandboxed frames send Origin: null on demand.
  • Switching the framework check off: Gorilla's CheckOrigin returning true, Spring's setAllowedOriginPatterns("*"), or ALLOWED_HOSTS = ["*"] behind Django Channels' AllowedHostsOriginValidator trusts every site.
  • SameSite alone: a sibling subdomain is same-site, and a cookie marked SameSite=None goes everywhere.
  • Ignoring XSS: script injected into your own origin passes every origin check. See cross-site scripting.

A developer review checklist

  1. List every WebSocket endpoint, including those opened by Socket.IO, SignalR, GraphQL subscriptions and development servers.
  2. Reject any handshake whose Origin is not an exact match for an origin you serve.
  3. Authenticate each connection with a short-lived, single-use ticket sent as the first message.
  4. Authorize every state-changing message on the server and validate its content like any other input.
  5. Close sockets on logout, session expiry and permission changes.
  6. Set SameSite, Secure and HttpOnly explicitly on session cookies.
  7. Retest from a cross-origin page: a foreign Origin must fail even with a valid cookie.

References

SelfSec scanner

Find Cross-Site Request Forgery in your own app

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.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list