Security·WebSocket·CSWSH

Cross-site WebSocket hijacking (CSWSH) in MCP servers: origin validation and upgrade protection

Cross-site WebSocket hijacking (CSWSH) is the WebSocket equivalent of CSRF. A malicious page can trigger a WebSocket connection to your MCP server from the victim's browser, using the victim's existing session cookies, and receive all tool responses. Unlike CSRF — which is typically limited to triggering state-changing requests — CSWSH can also read responses, making it a data exfiltration vulnerability. MCP servers that expose a browser-accessible WebSocket transport are directly in scope.

Why browsers are the threat model here

Most MCP servers run in a CLI or agent context — the transport is stdio or a machine-to-machine HTTP connection. CSWSH only applies when your MCP server exposes a WebSocket endpoint that browsers can connect to: a web-based MCP client, an in-browser Claude integration, or a developer tool that opens a WebSocket endpoint on localhost.

The key browser behavior that enables CSWSH: unlike the fetch API and XMLHttpRequest, browsers automatically include cookies and HTTP auth credentials on WebSocket upgrade requests to same-site origins and, without a SameSite cookie policy, to cross-site origins too. The attacker's page can initiate the WebSocket connection; they don't need to steal the cookies first.

CSWSH attack chain
  1. Victim is logged into your MCP web client at app.example.com, which maintains a session cookie mcp_session=abc123
  2. Victim visits an attacker-controlled page at evil.com
  3. Attacker's page runs: const ws = new WebSocket("wss://app.example.com/mcp");
  4. Browser sends the WebSocket upgrade request — including Cookie: mcp_session=abc123 — to app.example.com
  5. If the server doesn't validate the Origin header, it accepts the connection as authenticated
  6. Attacker's page can now call any MCP tool and read all responses using the victim's session

Defense 1: Origin header allowlist

The primary defense is to validate the Origin header on every WebSocket upgrade request. Reject upgrades from origins not on your allowlist:

// src/ws-transport.ts
import { WebSocketServer, WebSocket } from "ws";
import { IncomingMessage } from "http";

const ALLOWED_ORIGINS = new Set([
  "https://app.example.com",
  "https://staging.example.com",
  // Never include null, *, or undefined
]);

function isOriginAllowed(origin: string | undefined): boolean {
  if (!origin) return false; // reject missing Origin header
  try {
    const parsed = new URL(origin);
    // Normalize: drop trailing slash, force lowercase
    return ALLOWED_ORIGINS.has(`${parsed.protocol}//${parsed.host}`);
  } catch {
    return false; // reject malformed Origin
  }
}

const wss = new WebSocketServer({ noServer: true });

server.on("upgrade", (req: IncomingMessage, socket, head) => {
  const origin = req.headers.origin;

  if (!isOriginAllowed(origin)) {
    socket.write("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n");
    socket.destroy();
    return;
  }

  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit("connection", ws, req);
  });
});

Do not use origin.endsWith("example.com") — an attacker can register notexample.com and bypass this check. Use an exact match against a Set of permitted origins, after parsing with new URL().

Defense 2: CSRF token on WebSocket upgrade

For localhost MCP servers (common in development tools that expose a local WebSocket), the Origin header alone is insufficient — browsers may send Origin: null for file:// or sandbox iframe origins, and some browser extensions can forge Origin headers. Add a nonce-based CSRF token to the WebSocket handshake:

// Server: generate a per-session nonce and send it in the page HTML
// <script>window.__MCP_NONCE__ = "{{ nonce }}"</script>
// (rendered server-side; attacker's page cannot read it due to SOP)

// Client:
const ws = new WebSocket(
  `wss://localhost:3000/mcp?csrf=${window.__MCP_NONCE__}`
);

// Server: validate nonce on upgrade
server.on("upgrade", (req, socket, head) => {
  const params = new URLSearchParams(req.url?.split("?")[1] ?? "");
  const csrf = params.get("csrf");

  if (!validateCsrfToken(req, csrf)) {
    socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
    socket.destroy();
    return;
  }
  // ... proceed with upgrade
});

Defense 3: Per-message authentication (defense in depth)

Even with Origin validation and CSRF tokens, treat WebSocket connections as unauthenticated channels that must re-authenticate at the protocol level. Don't rely solely on the upgrade handshake for auth:

// First message on every WebSocket connection must be an auth frame
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
  let authenticated = false;

  const authTimeout = setTimeout(() => {
    if (!authenticated) {
      ws.close(4001, "Authentication timeout");
    }
  }, 5_000);

  ws.on("message", (data) => {
    const message = JSON.parse(data.toString());

    if (!authenticated) {
      if (message.type !== "auth" || !verifyJwt(message.token)) {
        ws.close(4003, "Unauthorized");
        clearTimeout(authTimeout);
        return;
      }
      authenticated = true;
      clearTimeout(authTimeout);
      ws.send(JSON.stringify({ type: "auth_ok" }));
      return;
    }

    // Only reach here after successful auth
    handleMcpMessage(ws, message);
  });
});

Per-message auth means that even if an attacker successfully hijacks a WebSocket connection (Origin bypass + cookie), they still can't call any MCP tools without a valid JWT that they'd need to steal separately — a much harder attack.

SameSite cookie configuration

Set SameSite=Strict or SameSite=Lax on your session cookies. This prevents browsers from sending cookies on cross-site WebSocket upgrade requests entirely, eliminating the fundamental CSWSH precondition:

// Express session cookie setup
app.use(session({
  secret: process.env.SESSION_SECRET!,
  cookie: {
    httpOnly: true,
    secure: true,         // HTTPS only
    sameSite: "strict",   // Never sent on cross-site requests
    maxAge: 3600_000,
  },
}));

Note: SameSite=Lax protects against CSWSH but allows cookies on top-level cross-site GET navigations. SameSite=Strict blocks cookies on all cross-site requests including those navigations — use Strict for session cookies on MCP server admin interfaces where users won't navigate directly from external links.

SkillAudit findings for CSWSH

HIGHWebSocket upgrade handler does not validate the Origin header — any origin can initiate authenticated connections using victim's session cookies
HIGHSession cookie missing SameSite attribute — cookies sent on cross-site WebSocket upgrade requests
MEDIUMOrigin validation uses string suffix match (endsWith) — bypassable with a lookalike domain (notexample.com)
MEDIUMNo per-message authentication on WebSocket transport — session hijack grants full tool access
LOWMissing CSRF nonce for localhost WebSocket endpoints — Origin: null bypass possible in some browser contexts

SkillAudit checks for Origin header validation in WebSocket upgrade handlers and SameSite on session cookies as part of the Security axis assessment. Run a free audit to see if your MCP server's WebSocket transport is vulnerable to CSWSH.