CWE-942: Permissive Cross-domain Security Policy with Untrusted Domains - Python
Overview
In Flask, Django, and FastAPI, this weakness usually appears as a CORS extension configured with a wildcard origin, or an after_request/middleware hook that copies the incoming Origin header straight into the response. All three have a maintained CORS layer - flask-cors, django-cors-headers, and FastAPI's built-in CORSMiddleware - that makes an explicit allowlist just as easy as a wildcard, so the fix is almost always a configuration change rather than new code.
Common Vulnerable Patterns
Flask with a wildcard
# VULNERABLE - flask-cors configured with a wildcard
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
Why this is vulnerable: origins: "*" allows every website to call the API cross-origin, and combining it with supports_credentials=True is worse than it looks. flask-cors does not send a literal * here - sending one requires send_wildcard=True, which defaults to False. It reflects the requesting origin instead. Asking this application for /api/data with Origin: https://evil.example returns Access-Control-Allow-Origin: https://evil.example alongside Access-Control-Allow-Credentials: true (measured on flask-cors 6.0.5), so the browser rule that a wildcard cannot be paired with credentials never engages, and any site can read a logged-in user's authenticated responses. Drop supports_credentials and the wildcard is still a finding: every response remains readable by every origin.
Manually reflecting the Origin header
# VULNERABLE - trusts whatever Origin the client sends
from flask import Flask, request
app = Flask(__name__)
@app.after_request
def add_cors_headers(response):
response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*')
response.headers['Access-Control-Allow-Credentials'] = 'true'
return response
Why this is vulnerable: Reflecting the Origin header unconditionally is equivalent to trusting every origin. Combined with Access-Control-Allow-Credentials: true, any website can make authenticated requests using the victim's session cookie and read the response.
Secure Patterns
Flask with flask-cors and an allowlist
# SECURE - explicit allowlist, credentials only for trusted origins
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
ALLOWED_ORIGINS = [
"https://app.example.com",
"https://www.example.com",
]
CORS(
app,
resources={r"/api/*": {"origins": ALLOWED_ORIGINS}},
supports_credentials=True,
methods=["GET", "POST"],
allow_headers=["Content-Type", "Authorization"],
)
Why this works: flask-cors only sets Access-Control-Allow-Origin when the request's origin exactly matches an entry in ALLOWED_ORIGINS; every other origin gets no CORS header and the browser blocks the response. Restricting methods and allow_headers limits what a permitted origin can do even after the origin check passes.
FastAPI with CORSMiddleware
# SECURE - FastAPI's built-in CORS middleware with an allowlist
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com", "https://www.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type", "Authorization"],
)
Why this works: CORSMiddleware validates the request's Origin against allow_origins before adding any CORS header, so unlisted origins receive none and cannot read the response. allow_credentials=True is safe here because of that explicit list, and for no other reason. The middleware will not stop you writing something worse: allow_origins=["*"] with allow_credentials=True raises no error and sends no literal * - Starlette reflects the requesting origin and puts Access-Control-Allow-Credentials: true beside it, which is the fully exploitable configuration rather than a blocked one. The allowlist is the whole of the control.
Django with django-cors-headers
# settings.py
# SECURE - explicit origin allowlist, no wildcard
INSTALLED_APPS = [
# ...
"corsheaders",
]
MIDDLEWARE = [
# CorsMiddleware must sit as high as possible, and in particular above any
# middleware that can return a response of its own (CommonMiddleware,
# WhiteNoise, a caching layer). Placed lower, those responses are returned
# without CORS headers and the browser blocks them.
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
# ...
]
CORS_ALLOWED_ORIGINS = [
"https://app.example.com",
"https://www.example.com",
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_METHODS = ["GET", "POST"]
CORS_ALLOW_HEADERS = ["content-type", "authorization"]
# Note: CORS_ALLOW_ALL_ORIGINS = True is the setting to grep for - it is the
# wildcard, and with CORS_ALLOW_CREDENTIALS it is worse than one. The older
# names CORS_ORIGIN_WHITELIST and CORS_ORIGIN_ALLOW_ALL still work as aliases,
# so a codebase may be using either spelling.
Why this works: CorsMiddleware compares the request's Origin against
CORS_ALLOWED_ORIGINS and only emits Access-Control-Allow-Origin when it
matches, so an unlisted origin gets no header and the browser refuses to expose
the response. The explicit list is what makes CORS_ALLOW_CREDENTIALS = True
safe, and nothing else is. Do not read the wildcard as failing closed here: the
middleware emits a literal * only when CORS_ALLOW_ALL_ORIGINS is set and
CORS_ALLOW_CREDENTIALS is not. Set both and it reflects the requesting origin
beside Access-Control-Allow-Credentials: true, which the browser accepts -
the same behaviour flask-cors and Starlette have, and the reason a "wildcard"
finding on a credentialed API is a live data leak rather than a hardening note.
Restricting methods and headers limits what a permitted origin can actually do.
If you need to match a family of origins, use CORS_ALLOWED_ORIGIN_REGEXES
rather than building the check yourself, and anchor the pattern at both ends.
The matcher is re.match, which anchors the start for you and leaves the end
open, so the trailing anchor is the one that matters:
https://app\.example\.com also matches
https://app.example.com.attacker.test, a domain the attacker registers.
Write ^https://app\.example\.com$.
Testing
- Send a request with
Origin: https://app.example.comand confirm the response echoes that exact origin inAccess-Control-Allow-Origin. - Send a request with
Origin: https://evil.exampleand confirm the response has noAccess-Control-Allow-Originheader. - Confirm
Access-Control-Allow-Credentials: truenever appears together withAccess-Control-Allow-Origin: *. This rules out one specific mistake only. Aflask-corswildcard reflects the origin rather than sending*, so the dangerous configuration passes this check and is caught by the previous bullet instead. - Send an
OPTIONSpreflight and confirm the allowed methods and headers match only what the route needs.
Common Pitfalls
CORSMiddleware'sallow_origin_regexoption looks safer than a wildcard, and Starlette's documentation warns against.*and.+on the grounds that they match URL-special characters like/,@,#and?. Starlette matches withre.fullmatch, and a browser-sentOriginis only ever a scheme, host and optional port, so those characters are not how a pattern like.*\.example\.comis reached in practice. What that pattern really leaves open is the part before the host, which it never pins: it matcheshttp://app.example.com, so a plaintext origin an on-path attacker can answer for is trusted. Follow the shape of Starlette's own example and include the scheme -https://[a-zA-Z0-9-]+\.example\.com. Replacing only the subdomain portion and leaving[a-zA-Z0-9-]+\.example\.commatches no browser origin at all underfullmatch, so it takes the API down rather than securing it.- Reading
flask-cors's wildcard handling as a safety net. There is no refusal to lean on:origins="*"makes the library reflect the request's origin rather than emit a literal*, sosupports_credentials=Trueyields a specific origin plusAccess-Control-Allow-Credentials: true, and every authenticated response is readable by any site. Emitting the literal*takessend_wildcard=True, which is off by default. Without credentials the wildcard is still the finding, because every response is exposed to every origin.