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
The correct fix: exact-match allowlist
import { WebSocketServer } from 'ws';
import http from 'http';
// Parse allowed origins from environment — never hardcode
const ALLOWED_ORIGINS = new Set(
(process.env.ALLOWED_ORIGINS ?? '').split(',').map(o => o.trim()).filter(Boolean)
);
function isAllowedOrigin(origin: string | undefined): boolean {
if (!origin) return false; // No origin header = reject
if (origin === 'null') return false; // Sandboxed iframe null origin = reject
return ALLOWED_ORIGINS.has(origin); // Exact match only
}
server.on('upgrade', (request, socket, head) => {
const origin = request.headers['origin'];
// Check origin BEFORE auth — fail fast on cross-site requests
if (!isAllowedOrigin(origin)) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
// Then verify auth
const cookie = parseCookies(request.headers.cookie);
const session = verifySession(cookie.session_token);
if (!session) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request, session);
});
});
ALLOWED_ORIGINS format: Include the full origin with scheme: https://app.myapp.com,https://admin.myapp.com. Do not include trailing slashes. Never use wildcards — list each allowed origin explicitly. In development, add http://localhost:3001 but ensure production deployments override this with the production value.
Localhost WebSocket servers: nonce-based CSRF
For MCP servers running on localhost (Claude Code extensions, desktop apps), the Origin check is weaker — the browser can be on any page and the MCP server has no natural list of allowed origins. The correct mitigation is a nonce-based CSRF token:
- On startup, the MCP server generates a random nonce and writes it to a file that only the local application can read (e.g.
~/.config/mcp-server/ws-nonce). - The trusted local client (Claude Code) reads the nonce file and includes the nonce in the WebSocket URL as a query parameter:
ws://localhost:3000/?nonce=<value>. - The server validates the nonce on upgrade. Attacker pages cannot access the nonce file.
import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
const NONCE_PATH = path.join(os.homedir(), '.config', 'mcp-server', 'ws-nonce');
const WS_NONCE = crypto.randomBytes(32).toString('hex');
// Write nonce on startup (file permission: readable only by owner)
fs.mkdirSync(path.dirname(NONCE_PATH), { recursive: true, mode: 0o700 });
fs.writeFileSync(NONCE_PATH, WS_NONCE, { mode: 0o600 });
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url!, `http://localhost`);
const nonce = url.searchParams.get('nonce');
if (!nonce || !crypto.timingSafeEqual(
Buffer.from(nonce, 'hex'),
Buffer.from(WS_NONCE, 'hex')
)) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
// Proceed with connection
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
Defense-in-depth: per-message JWT authentication
Origin validation closes the CSWSH attack. But for MCP servers with long-lived agent sessions, add per-message authentication as a second layer. Even if an attacker bypasses the Origin check, per-message auth limits the blast radius to unauthenticated calls only:
// On every incoming message, re-verify the bearer token
// This is separate from the upgrade-time Origin check
wss.on('connection', (ws, request, session) => {
ws.on('message', async (data) => {
let msg;
try { msg = JSON.parse(data.toString()); } catch { ws.close(4400, 'invalid_json'); return; }
// Per-message auth: token must be included in every message
const token = msg.auth?.bearer;
if (!token) { ws.close(4401, 'auth_required'); return; }
try {
const payload = await verifyToken(token); // Full JWT verify on every message
await handleMcpMessage(msg, payload, ws);
} catch (err) {
ws.close(4401, 'auth_failed');
}
});
// Auth timeout: close if first message doesn't arrive within 5 seconds
const authTimeout = setTimeout(() => ws.close(4401, 'auth_timeout'), 5000);
ws.once('message', () => clearTimeout(authTimeout));
});
SkillAudit findings
endsWith() or unanchored regex — attacker-controlled domain passes validation. Security axis −16 pts.
Run a SkillAudit scan on your MCP server's GitHub URL or npm package name to get the full WebSocket security assessment, including upgrade handler analysis, origin validation, and message-level auth checks.