Use the cors middleware with an origin function that checks an exact allowlist of full origins from configuration, and credentials: true. Listed origins get their own origin back with Access-Control-Allow-Credentials; every other origin, null included, gets no CORS headers, and every response varies on Origin. Never use origin: true, a RegExp or a contains check.
- Find every place that sets Access-Control-Allow-Origin: cors() options (origin: true, '*', a RegExp, an array or a function) and hand-written res.set() or res.header() calls that copy req.headers.origin.
- Put the exact origins of your front ends in configuration, per environment, as serialized origins such as https://portal.example.test: scheme, host and port, with no path and no trailing slash.
- Mount cors() on the API prefix before the routes, with origin: (origin, callback) => callback(null, allowedOrigins.includes(origin)), credentials: true, and only the methods and request headers your front ends send.
- Add Vary: Origin to every API response with res.vary('Origin') before cors runs: cors adds it only when it grants, and a cache that stores a refusal, or the response to a request without Origin, without it can replay that to a listed front end.
- Remove null, http:// origins and development hosts such as http://localhost:5173 from the production list.
- Send Cache-Control: no-store (or private) on per-user API responses so that no shared cache keeps them.
- Keep CSRF protection on cookie-authenticated writes and require Content-Type: application/json, so that a cross-origin write needs a preflight that the allowlist refuses.
- Add regression tests that send credentialed requests from a foreign origin, Origin: null, look-alike hosts, the http:// version of a front end and a sibling subdomain, and assert that a browser would not share the response.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
export function corsMiddleware() {
return cors({
// origin: '*' broke the front end's credentialed fetch (browsers refuse * with credentials),
// so echo the request's Origin back instead, whatever it is.
origin: true,
credentials: true,
});
}With IMPL=vulnerable, 8 of 13 tests fail (8 exploit tests).
The fix
export function corsMiddleware(allowedOrigins) {
// allowedOrigins comes from configuration: ['https://portal.example.test', 'https://admin.example.test']
const policy = cors({
// Exact string match on the whole origin: scheme, host and port. A refused origin makes
// the middleware send no CORS headers at all, not even Access-Control-Allow-Credentials.
origin: (origin, callback) => callback(null, allowedOrigins.includes(origin)),
credentials: true,
methods: ['GET', 'PUT'],
allowedHeaders: ['Content-Type'],
maxAge: 600,
});
return (req, res, next) => {
// cors adds Vary: Origin only when it grants. Refusals and requests without Origin vary on
// it too, so a shared cache never replays a refusal to a listed origin, or a grant to another.
res.vary('Origin');
policy(req, res, next);
};
}With IMPL=fixed, 13 of 13 tests pass.
A fix that looks safe but is not
export function corsMiddleware() {
return cors({
origin: /example\.test/, // portal.example.test, admin.example.test and whatever comes next
credentials: true,
methods: ['GET', 'PUT'],
allowedHeaders: ['Content-Type'],
maxAge: 600,
});
}With IMPL=wrong, 5 of 13 tests fail (5 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
describe(`exploit (IMPL=${implName})`, () => {
// Each request is what a page on the attacker's origin gets from
// fetch('https://api.example.test/api/invoices', { credentials: 'include' }): the browser
// attaches Alice's cookie, and the API's CORS headers decide whether the page may read the reply.
test("an arbitrary origin cannot read Alice's invoices", async () => {
assertNotShared(await readInvoices(EVIL), EVIL);
});
test('Origin: null, from a sandboxed iframe or a data: URL, cannot read them', async () => {
assertNotShared(await readInvoices('null'), 'null');
});
test('a domain that merely ends with example.test cannot read them', async () => {
assertNotShared(await readInvoices(SUFFIX_LOOKALIKE), SUFFIX_LOOKALIKE);
});
test("a domain that starts with the portal's origin cannot read them", async () => {
assertNotShared(await readInvoices(PREFIX_LOOKALIKE), PREFIX_LOOKALIKE);
});
test('a host that matches only because a regex dot matches any character cannot read them', async () => {
assertNotShared(await readInvoices(DOT_LOOKALIKE), DOT_LOOKALIKE);
});
test("the portal's host over plain HTTP cannot read them", async () => {
// Anyone on the network path can inject script into an http:// page.
assertNotShared(await readInvoices(PLAIN_HTTP_PORTAL), PLAIN_HTTP_PORTAL);
});
test('a sibling subdomain that is not on the list cannot read them', async () => {
// One XSS or subdomain takeover on forum.example.test would otherwise expose the API.
assertNotShared(await readInvoices(SIBLING), SIBLING);
});
test("an arbitrary origin's preflight is refused, so its credentialed update is never sent", async () => {
const result = await changeEmail(EVIL, 'mallory@evil.example');
assert.equal(users.get('alice').email, 'alice@example.test', `the cross-origin PUT changed Alice's email (${describeCors(result.res)})`);
assert.equal(result.sent, false, `the browser would send the PUT after this preflight (${describeCors(result.res)})`);
});
});- cors({ origin: '*', credentials: true }) sends Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. Browsers refuse that combination for credentialed requests, so the front end breaks; the usual fix, origin: true, echoes every origin back, null included.
- A RegExp origin is tested with RegExp.prototype.test(), which matches anywhere unless anchored: /example\.test/ accepts https://portal.example.test.evil.example. The README's own example, /example\.com$/, accepts https://evil-example.com; with /example\.test$/ the middleware echoed https://evil-example.test with credentials.
- The array and RegExp forms compare exactly or by pattern, but they still send Access-Control-Allow-Credentials: true and Vary: Origin to refused origins. Browsers ignore that without a matching Access-Control-Allow-Origin, so it is harmless. The origin function used here sends refused origins no CORS headers at all, Vary included, so the fixed middleware sets Vary: Origin itself.
- Pass false, not an Error, to the origin callback for a refused origin: callback(new Error(...)) sends the request to Express's error handler, so refused origins get a 500 instead of a normal response without CORS headers.
- cors only decides the response headers. A request from a refused origin still runs the handler and gets its normal response, which the browser then withholds from the page; a refused preflight falls through to Express, which answers OPTIONS with 200 and an Allow header but no CORS headers, so the browser refuses it. Non-browser clients ignore CORS entirely, so authorization stays on the server.
- Whether the session cookie reaches the API at all depends on the browser: a SameSite=Lax cookie is not sent on cross-site fetches but is sent from sibling subdomains, and a SameSite=None cookie, needed for a front end on another site, is sent from any site in Chrome, which does not block third-party cookies by default. The allowlist is the control that does not vary by browser.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs cors-misconfiguration/node-express does this for you).
- Against your own API, replay a credentialed request with a foreign origin, for example curl -i -H 'Origin: https://evil.example' -b 'sid=...' https://your.api/api/invoices, and expect neither Access-Control-Allow-Origin nor Access-Control-Allow-Credentials, but Vary: Origin; repeat with Origin: null, a look-alike host, the http:// version of your front end and a preflight (-X OPTIONS -H 'Access-Control-Request-Method: PUT').
- Scanners cover only part of this: CodeQL's js/cors-permissive-configuration should catch origin: true but not a RegExp or a function, and Semgrep's Express rule only sees hand-written headers, so the regression tests are the control for pattern mistakes.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.express.security.cors-misconfiguration.cors-misconfiguration | No findings on app.js or impl/*.js. The rule is taint from req.query, req.body, req.params, req.cookies or req.headers into an Access-Control-Allow-Origin value set with res.set(), res.header(), res.setHeader() or res.writeHead() inside a route handler, so it never sees the options passed to the cors package. On a probe handler that copies req.headers.origin into the header with res.set() it reported that line.Checked: Semgrep 1.180.0 (CE), semgrep-rules develop at 9bbf021 |
| Semgrep | typescript.lang.security.audit.cors-regex-wildcard.cors-regex-wildcard | Not applicable here: the rule runs on TypeScript only and reports an unescaped dot in a RegExp literal assigned to a variable whose name contains cors. It scanned none of this card's .js files; on a probe .ts file containing corsOrigins = [/portal.example.test$/] it reported the pattern. It would not report impl/wrong.js's /example\.test/, whose dots are escaped and whose real flaw is the missing anchors.Checked: Semgrep 1.180.0 (CE), semgrep-rules develop at 9bbf021 |
| CodeQL | js/cors-permissive-configuration | From the query source and javascript/ql/lib/ext/cors.model.yml: the origin option of cors() is a sink, and true, null, "*" or remote input reaching it is reported unless the same options set credentials: false. Expected to flag impl/vulnerable.js (origin: true). A RegExp or a function is not a modelled source, so impl/wrong.js and impl/fixed.js should not be reported.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | js/cors-misconfiguration-for-credentials | Not expected to report anything on this card: it tracks remote input or a null value into an Access-Control-Allow-Origin header that a route handler sets explicitly next to Access-Control-Allow-Credentials: true, and here the cors package sets the headers.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| cors | 2.8.6 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the cors-misconfiguration/node-expressfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs cors-misconfiguration/node-express runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- express 5.3.0
- cors 2.8.6
- Matrix
- fixed: 13 of 13 tests pass; vulnerable: 8 of 13 tests fail (8 exploit tests); wrong: 5 of 13 tests fail (5 exploit tests)
- Test results
- fixed 13/13 pass; vulnerable 8/13 fail (all 8 exploit tests); wrong 5/13 fail (5 exploit tests), offline after npm ci
- Dependency freshness
- cors 2.8.6 was published on 2026-01-22; its only dependencies are object-assign 4.1.1 and vary 1.1.2. express 5.3.0 was published on 2026-10-09 and is taken at one day old because it is a security release (qs CVE-2026-2391, CVE-2026-82417 and CVE-2026-82562; proxy-addr CVE-2026-90711). npm audit found 0 vulnerabilities and npm audit signatures verified all 67 packages. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
- Notes
- Header behaviour checked with probe apps on cors 2.8.6: { origin: '*', credentials: true } sent Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true; the array and RegExp forms sent Access-Control-Allow-Credentials: true and Vary: Origin to a refused origin; the origin function sent nothing to a refused origin or to a request without Origin; a refused preflight got Express's 200 with Allow: GET, HEAD, PUT; /example\.test$/ echoed https://evil-example.test.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- cors (expressjs/cors) README: origin option values and dynamic origins
- cors 2.8.6 source: lib/index.js
- WHATWG Fetch Standard: CORS protocol and the CORS check
- MDN: Cross-Origin Resource Sharing (CORS)
- MDN: Access-Control-Allow-Credentials
- OWASP HTML5 Security Cheat Sheet: Cross Origin Resource Sharing
- PortSwigger Web Security Academy: CORS
- MDN: Third-party cookies (browser defaults)