FlawOpen
● CWE-942 · OWASP A05:2021

CORS Misconfiguration (CWE-942): How Origin Reflection Leaks Data and How to Fix It

An API that answers any Origin with Access-Control-Allow-Origin set to that origin and Access-Control-Allow-Credentials: true lets every website read its responses with the visitor's cookies. How suffix checks, the null origin and reflected origins break the same-origin policy, and the exact-match allowlist that fixes it, with an Express and cors example.

Plain English Explainer (ELI5)

A bank clerk will read your balance aloud to whoever you send, as long as the messenger shows a letter naming who they work for and the clerk's list approves that name. One clerk's rule is 'approve any name that ends in the word Smith'. A stranger sends a messenger with a letter from 'Goldsmith', the clerk approves it, and your balance is read out to someone you never sent. Origin reflection is the lazy version where the clerk approves every name. The fix is a short, exact list of names, compared letter for letter.

Core Concepts & Subsystem Terms

Same-origin policy
Browsers let a script read responses only from its own origin (scheme, host and port). CORS response headers are how a server deliberately relaxes that rule for named origins.
Access-Control-Allow-Origin with credentials
If the response names the caller's origin in Access-Control-Allow-Origin and sends Access-Control-Allow-Credentials: true, a script on that origin can read responses to requests that carried the user's cookies. Browsers refuse * together with credentials, which is why misconfigured servers echo the origin instead.
Origin reflection and suffix matching
Copying the Origin header into the response trusts every site. Checks such as endsWith('example.com'), substring matches or unanchored regular expressions with unescaped dots also accept https://attacker-example.com.
The null origin
Sandboxed iframes, data: URLs and some redirects send Origin: null. Any attacker can produce it, so allowing null is the same as allowing everyone.
Vary: Origin
When Access-Control-Allow-Origin depends on the request, the response must carry Vary: Origin so shared caches do not serve one origin's CORS headers to another. The cors package adds it for dynamic origins.

Step-by-Step Attack Flow

Step 1

The victim is signed in to the API's site

Their browser holds a session cookie for api.example.com that is sent on cross-site requests.

Step 2

They open a page on the attacker's domain

A script calls fetch('https://api.example.com/api/account', { credentials: 'include' }). A page on https://attacker-example.com that only logs the response length to the console illustrates the case.

Step 3

The API approves the origin

The suffix check accepts attacker-example.com, so the response carries that origin in Access-Control-Allow-Origin together with Access-Control-Allow-Credentials: true.

Step 4

The script reads private data

The browser hands the account JSON to the attacker's script, which can send it anywhere. In 2016 James Kettle found this pattern on several Bitcoin exchanges, where it exposed API keys and wallet data.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// server.js: any origin ending in example.com, plus null, may read with cookies
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';

const app = express();

// true makes cors echo the caller's Origin together with credentials
app.use(cors({
  origin: (origin, callback) => callback(null, !origin || origin === 'null' || origin.endsWith('example.com')),
  credentials: true,
}));

// private account data readable by the approved origins
app.get('/api/account', requireSession, (req, res) => {
  res.json(accountFor(req.session.userId));
});
HARDENED SECURE PATCH
// server.js: two exact origins, nothing else
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';

const app = express();

// full origins with scheme and host, compared exactly
const ALLOWED_ORIGINS = new Set(['https://app.example.com', 'https://admin.example.com']);

// unknown or missing Origin gets no CORS headers; cors adds Vary: Origin
app.use(cors({
  origin: (origin, callback) => callback(null, origin !== undefined && ALLOWED_ORIGINS.has(origin)),
  credentials: true,
  methods: ['GET', 'POST'],
  maxAge: 600,
}));

// personal data is never stored by shared caches
app.get('/api/account', requireSession, (req, res) => {
  res.set('Cache-Control', 'no-store');
  res.json(accountFor(req.session.userId));
});

Engineering & System Hardening Checklist

Sources

Framework mappings

← Browse the full security directory All vulnerability guides →