Skip to content

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-Origin value.
  • Confirm a request from an untrusted origin receives no Access-Control-Allow-Origin header (or a non-matching one) and that the browser blocks reading the response.
  • Confirm Access-Control-Allow-Credentials is never present alongside Access-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 OPTIONS preflight and confirm Access-Control-Allow-Methods/Access-Control-Allow-Headers only list what the endpoint supports.

Common Pitfalls

  • Using the cors package's own documented RegExp style, such as origin: /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, including https://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.com matches 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: true in the cors package's configuration - it reads like "enable the origin check", but it reflects whatever Origin header 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 default origin value is *, so an unconfigured call is a wildcard. That default often survives alongside route-specific cors({...}) middleware added later, still applying to every route the newer configuration does not cover.

Additional Resources