CWE-942: Permissive Cross-domain Security Policy with Untrusted Domains - JavaScript
Overview
In Node.js APIs, this weakness usually shows up as a response header granting Access-Control-Allow-Origin to every origin, or echoing the request's Origin header back without checking it against an allowlist. Express has no built-in CORS handling, so that header comes either from the cors package, misconfigured, or from a middleware written by hand.
Common Vulnerable Patterns
Wildcard origin
// VULNERABLE - allows any website to read the response
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
next();
});
Why this is vulnerable: Any site can fetch this API cross-origin and read the response in the browser, including sites designed to steal data from logged-in users.
Reflecting the Origin header
// VULNERABLE - trusts whatever origin the caller sends
app.use((req, res, next) => {
const origin = req.headers.origin;
res.setHeader('Access-Control-Allow-Origin', origin || '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
next();
});
Why this is vulnerable: Reflecting the Origin header is equivalent to a wildcard - every origin passes the check. Combined with Access-Control-Allow-Credentials: true, this lets any website make authenticated requests using the victim's cookies and read the response.
The || '*' fallback is not incidental: res.setHeader throws ERR_HTTP_INVALID_HEADER_VALUE when handed undefined, so a reflection written without it fails every same-origin request with a 500 and gets noticed and "fixed" by adding exactly this fallback. The reflection, which is the actual weakness, survives.
Secure Patterns
Allowlist with the cors package
// SECURE - explicit allowlist, credentials only for trusted origins
const cors = require('cors');
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://www.example.com'
];
app.use(cors({
origin(origin, callback) {
// requests with no Origin header (curl, server-to-server) are not
// browser cross-origin requests; decide deliberately whether to allow them
if (!origin || ALLOWED_ORIGINS.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Origin not allowed'));
}
},
credentials: true,
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
Why this works: The origin callback only allows exact matches from a fixed allowlist, so an attacker's site never receives a matching Access-Control-Allow-Origin header. Restricting methods and allowedHeaders to what the API actually needs limits what a permitted origin can do, and credentials: true is safe here specifically because it is paired with an explicit allowlist rather than a wildcard.
Manual allowlist check
// SECURE - manual middleware with the same allowlist logic
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://www.example.com'
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
// set unconditionally, so a shared cache keys on Origin even for the
// responses that got no CORS headers at all
res.setHeader('Vary', 'Origin');
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
// no Access-Control-Allow-Origin for unrecognized origins - the browser
// blocks the page from reading the response
if (req.method === 'OPTIONS') {
return res.status(204).end(); // answer the preflight; no route handler runs
}
next();
});
Why this works: The Access-Control-Allow-Origin header is only set when the origin exactly matches an entry in the allowlist; every other request gets no CORS header, so the browser's same-origin policy blocks the page from reading the response.
Three parts of this are easy to leave out and all are load-bearing. Credentials have to be granted explicitly. A browser discards the response to a credentials: 'include' request unless Access-Control-Allow-Credentials: true comes back, whatever the origin header says - so a middleware that sets only Access-Control-Allow-Origin blocks the authenticated calls this page is about while looking correct in a curl transcript, which shows the header and has no same-origin policy to enforce. It is safe here for the same reason it is safe in the cors example above: the allowlist ran first. Drop this line if the API genuinely serves no credentialed requests, and drop the cookies with it. The preflight has to be answered. A browser will not send a POST carrying Authorization or a JSON Content-Type until an OPTIONS request comes back listing that method and header, so a middleware that sets only Access-Control-Allow-Origin permits simple GETs and silently blocks everything else - the cors package handles this for you, a hand-written middleware does not. And Vary: Origin belongs on every response, not just the allowed ones. Setting it only inside the if leaves the rejected responses cacheable without an Origin key, so a shared cache can store the header-less response and hand it to an allowed origin afterwards.
Testing
// From the browser console on an untrusted origin (e.g. https://evil.example)
fetch('https://api.example.com/data', { credentials: 'include' })
.then(r => r.json())
.then(data => console.log('Leaked:', data))
.catch(e => console.log('Correctly blocked:', e));
- Confirm a request from an allowed origin succeeds and receives the expected
Access-Control-Allow-Originvalue. - Confirm a request from an untrusted origin receives no
Access-Control-Allow-Originheader (or a non-matching one) and that the browser blocks reading the response. - Confirm
Access-Control-Allow-Credentialsis never present alongsideAccess-Control-Allow-Origin: *. This rules out one specific mistake only - a middleware that reflects the origin never emits a literal*and passes the check unchanged, which is why the previous bullet is the one that matters. - Send an
OPTIONSpreflight and confirmAccess-Control-Allow-Methods/Access-Control-Allow-Headersonly list what the endpoint supports.
Common Pitfalls
- Using the
corspackage's own documented RegExp style, such asorigin: /example\.com$/, to "match subdomains" - the package tests the pattern against the whole origin string, so a pattern anchored only at the end matches anything ending in that text, includinghttps://evilexample.com. Adding the escaped literal dot (/\.example\.com$/) closes that hole and leaves a second one: with nothing anchoring the front,http://app.example.commatches too, so anyone who can answer plaintext HTTP on the network path holds a trusted origin. Anchor both ends and pin the scheme -/^https:\/\/[a-z0-9-]+\.example\.com$/- or keep an explicit list of subdomains and skip the regex. - Setting
origin: truein thecorspackage's configuration - it reads like "enable the origin check", but it reflects whateverOriginheader the request sends, which is the same behavior as manually echoing it. - Calling
app.use(cors())with no arguments as a quick fix or for local development - the package's defaultoriginvalue is*, so an unconfigured call is a wildcard. That default often survives alongside route-specificcors({...})middleware added later, still applying to every route the newer configuration does not cover.