Back to scan results
What this check probes
Three test requests, each with a different Origin header:
Origin: https://attacker.example — a clearly hostile origin. We look at Access-Control-Allow-Origin in the response.
Origin: https://yourdomain.attacker.example — tests for naive substring matching.
Origin: null — sandboxed iframes and some local-file contexts send this; many CORS implementations naively allow it.
For each, we flag a finding when:
Access-Control-Allow-Origin reflects the value we sent (any-origin acceptance).
Access-Control-Allow-Origin: * is set and Access-Control-Allow-Credentials: true is also set (browsers reject this combo, but if you've coded around it the server is misconfigured anyway).
Access-Control-Allow-Origin: null is returned (almost always wrong).
- A wildcard subdomain pattern matches a hostile lookalike (e.g.,
*.example.com incorrectly accepts evil.example.com.attacker.com).
Why this matters for PCI DSS
The same-origin policy is what stops evil.com from reading data fetched from your-bank.com while you're logged into both. CORS is the documented hole in same-origin — when your server returns the right headers, browsers are willing to make the cross-origin response readable.
If your server reflects any origin and sends Access-Control-Allow-Credentials: true, then any malicious page a logged-in user visits can:
fetch('https://your-bank.com/account/transactions', {credentials: 'include'})
.then(r => r.json()).then(data => sendToAttacker(data));
...and exfiltrate the user's authenticated data.
PCI DSS 4.0 Requirement 6.2.4 covers cross-site request forgery and information disclosure — both fall out of broken CORS.
How to fix it
Default to no CORS headers at all. If your API is only called by your own first-party frontend (same origin), you don't need CORS — leave the headers out and the browser will enforce same-origin.
If you genuinely need cross-origin access, maintain an explicit allow-list of trusted origins:
Express (Node.js):
const cors = require('cors');
const allowed = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
app.use(cors({
origin: (origin, cb) => {
if (!origin || allowed.has(origin)) cb(null, origin);
else cb(new Error('CORS blocked'));
},
credentials: true,
}));
nginx:
map $http_origin $cors_origin {
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
default "";
}
server {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary "Origin" always;
}
ASP.NET Core:
builder.Services.AddCors(o => o.AddPolicy("Default", b => b
.WithOrigins("https://app.example.com", "https://admin.example.com")
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod()));
Rules to live by:
- Never use a wildcard
* on an endpoint that returns sensitive data.
- Never reflect
Origin without first matching it against an exact-match allow-list.
- Never use substring or regex prefix matching that allows attacker-controlled subdomains.
- Never accept
null as a valid origin.
- Always include
Vary: Origin when the response varies based on origin (so caches don't serve the wrong response).
Test with CORScanner or the curl -H "Origin: ..." command.