Security reference · WebSocket · CSRF

MCP server WebSocket origin validation security

Cross-site WebSocket hijacking (CSWSH) exploits the fact that browsers automatically send cookies on WebSocket upgrade requests to any origin — there is no same-origin restriction for WebSocket, unlike for cross-origin XHR. If your MCP server's WebSocket upgrade handler does not validate the Origin header against an allowlist, a malicious website can open a WebSocket connection to your server using the victim's authenticated cookies, then issue tool calls on their behalf. This reference covers the attack, three common bypass patterns in defective allowlists, and the correct mitigations including nonce-based CSRF tokens for localhost WebSocket servers.

The CSWSH attack model

A browser's same-origin policy blocks cross-origin XHR and fetch requests unless the server opts in via CORS. But the WebSocket handshake uses an HTTP Upgrade request, and browsers send cookies on Upgrade requests to any origin — the same-origin policy does not apply. This means a malicious page at attacker.com can open a ws://mcp-server.local:3000/ WebSocket connection and the browser will include any mcp-server.local cookies in the Upgrade request. If the server grants the connection based solely on the presence of a valid cookie, the attacker has a fully authenticated WebSocket session.

The Origin header is the browser's signal of where the WebSocket request originated. Unlike cookies, the Origin header cannot be set by JavaScript — the browser sets it automatically based on the page's origin. Validating Origin against an allowlist is the first line of defense.

localhost MCP servers are equally at risk: A Claude Code extension or desktop MCP server listening on ws://localhost:3000 is reachable from any browser tab. An attacker who can get the victim to visit a web page can establish a WebSocket to localhost:3000 — the machine firewall does not protect against browser-originating requests from the local machine itself.

The vulnerable pattern: no Origin check

import { WebSocketServer } from 'ws';
import http from 'http';

const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true });

// VULNERABLE: upgrade is granted based only on cookie auth
// Any cross-site page can establish a WebSocket to this server
server.on('upgrade', (request, socket, head) => {
  const cookie = parseCookies(request.headers.cookie);
  const session = verifySession(cookie.session_token);  // valid = allow
  if (!session) {
    socket.destroy();
    return;
  }
  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit('connection', ws, request, session);
  });
});

Common bypass patterns in defective allowlists

Many servers add an Origin check but implement it in ways that can be bypassed:

Bypass 1 — endsWith() instead of exact match

// VULNERABLE: endsWith() allows attacker.myapp.com, evilmyapp.com
function isAllowedOrigin(origin) {
  return origin.endsWith('myapp.com');
}
// Bypass: attacker.com/redirect?to=ws://evilmyapp.com:3000/ — no, but
// Direct bypass: origin = 'evil-myapp.com' → endsWith('myapp.com') = true

Bypass 2 — Regex without anchors or escaping

// VULNERABLE: unescaped dot matches any character
const ALLOWED_PATTERN = /https:\/\/myapp\.com/;
// Bypass: origin = 'https://myappXcom.attacker.com' → matches
// ALSO VULNERABLE: no end anchor
const ANCHORED_BUT_WRONG = /^https:\/\/myapp\.com/;
// Bypass: origin = 'https://myapp.com.attacker.com' → matches (no end anchor)

Bypass 3 — Null origin accepted

// VULNERABLE: null origin accepted (sandboxed iframe can send null origin)
function isAllowedOrigin(origin) {
  return !origin || ALLOWED_ORIGINS.includes(origin);  // !origin = true for null/undefined
}
// Bypass: attacker uses