Skip to content

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.com and confirm the response echoes that exact origin in Access-Control-Allow-Origin.
  • Send a request with Origin: https://evil.example and confirm the response has no Access-Control-Allow-Origin header.
  • Confirm Access-Control-Allow-Credentials: true never appears together with Access-Control-Allow-Origin: *. This rules out one specific mistake only. A flask-cors wildcard reflects the origin rather than sending *, so the dangerous configuration passes this check and is caught by the previous bullet instead.
  • Send an OPTIONS preflight and confirm the allowed methods and headers match only what the route needs.

Common Pitfalls

  • CORSMiddleware's allow_origin_regex option 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 with re.fullmatch, and a browser-sent Origin is only ever a scheme, host and optional port, so those characters are not how a pattern like .*\.example\.com is reached in practice. What that pattern really leaves open is the part before the host, which it never pins: it matches http://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\.com matches no browser origin at all under fullmatch, 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 *, so supports_credentials=True yields a specific origin plus Access-Control-Allow-Credentials: true, and every authenticated response is readable by any site. Emitting the literal * takes send_wildcard=True, which is off by default. Without credentials the wildcard is still the finding, because every response is exposed to every origin.

Additional Resources