Register flask-cors for the API routes with exact origins from configuration and supports_credentials=True. Literal origins are compared as whole strings, ignoring case: listed origins get their own origin back with credentials, others, null included, get no CORS headers, and a hook adds Vary: Origin to every API response. Never keep the default origins="*" with credentials or use regex origins.
- Find every CORS(...) and @cross_origin(...) call, CORS_* configuration keys and after_request hooks that set Access-Control-Allow-Origin.
- Pass an explicit list of serialized origins (scheme, host and port) from configuration: CORS(app, resources={r"/api/*": {"origins": allowed_origins}}, supports_credentials=True, ...).
- Keep every entry a literal URL. flask-cors treats an origin containing *, \, ?, $, ^, [, ], ( or ) as a regular expression and matches it with re.match, which anchors only at the start.
- Limit methods and allow_headers to what your front ends send, and set always_send=False so that requests without an Origin header get no CORS headers.
- Remove null, http:// origins and development hosts such as http://localhost:5173 from the production list, and send Cache-Control: no-store (or private) on per-user responses.
- Keep CSRF protection on cookie-authenticated writes and require a JSON body, 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
def enable_cors(app: Flask, allowed_origins: list[str]) -> None:
# The front end's fetch(..., {credentials: "include"}) failed until credentials were allowed.
CORS(app, supports_credentials=True) # origins defaults to "*"With IMPL=vulnerable, 9 of 13 tests fail (8 exploit tests).
The fix
def enable_cors(app: Flask, allowed_origins: list[str]) -> None:
# allowed_origins comes from configuration: ["https://portal.example.test", "https://admin.example.test"]
# Registered before CORS(), so it runs after flask-cors's own hook (Flask runs them in reverse).
# flask-cors adds Vary: Origin only when it echoes an origin and the list has several entries;
# 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.
@app.after_request
def vary_on_origin(response: Response) -> Response:
if request.path.startswith("/api/"):
response.vary.add("Origin")
return response
CORS(
app,
# Plain strings are compared exactly (ignoring case). An entry containing *, \, ?, $, ^,
# [, ], ( or ) would be treated as a regular expression, so keep patterns out of the list.
resources={r"/api/*": {"origins": allowed_origins}},
supports_credentials=True,
methods=["GET", "PUT"],
allow_headers=["Content-Type"],
max_age=600,
always_send=False, # no CORS headers at all on requests that carry no Origin
)With IMPL=fixed, 13 of 13 tests pass.
A fix that looks safe but is not
def enable_cors(app: Flask, allowed_origins: list[str]) -> None:
CORS(
app,
# portal.example.test, admin.example.test and whatever comes next
resources={r"/api/*": {"origins": [r".*example\.test"]}},
supports_credentials=True,
methods=["GET", "PUT"],
allow_headers=["Content-Type"],
max_age=600,
)With IMPL=wrong, 6 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.
# Exploit: 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.
def test_exploit_an_arbitrary_origin_cannot_read_alices_invoices(browser):
assert_not_shared(read_invoices(browser, EVIL), EVIL)
def test_exploit_origin_null_from_a_sandboxed_iframe_or_data_url_cannot_read_them(browser):
assert_not_shared(read_invoices(browser, "null"), "null")
def test_exploit_a_domain_that_merely_ends_with_example_test_cannot_read_them(browser):
assert_not_shared(read_invoices(browser, SUFFIX_LOOKALIKE), SUFFIX_LOOKALIKE)
def test_exploit_a_domain_that_starts_with_the_portals_origin_cannot_read_them(browser):
assert_not_shared(read_invoices(browser, PREFIX_LOOKALIKE), PREFIX_LOOKALIKE)
def test_exploit_a_host_that_matches_only_because_a_regex_dot_matches_any_character_cannot_read_them(browser):
assert_not_shared(read_invoices(browser, DOT_LOOKALIKE), DOT_LOOKALIKE)
def test_exploit_the_portals_host_over_plain_http_cannot_read_them(browser):
# Anyone on the network path can inject script into an http:// page.
assert_not_shared(read_invoices(browser, PLAIN_HTTP_PORTAL), PLAIN_HTTP_PORTAL)
def test_exploit_a_sibling_subdomain_that_is_not_on_the_list_cannot_read_them(browser):
# One XSS or subdomain takeover on forum.example.test would otherwise expose the API.
assert_not_shared(read_invoices(browser, SIBLING), SIBLING)
def test_exploit_an_arbitrary_origins_preflight_is_refused_so_its_credentialed_update_is_never_sent(browser, users):
result = change_email(browser, EVIL, "mallory@evil.example")
assert users["alice"]["email"] == "alice@example.test", f"the cross-origin PUT changed Alice's email ({describe_cors(result.res)})"
assert result.sent is False, f"the browser would send the PUT after this preflight ({describe_cors(result.res)})"- CORS(app, supports_credentials=True) leaves origins at its default "*", and flask-cors then echoes whatever Origin arrives, null included, with Access-Control-Allow-Credentials: true. It raises ValueError only if send_wildcard=True is set as well; the default does not complain.
- Regex origins are matched with re.match, which anchors only at the start: r"https://.*\.example\.test" echoed https://portal.example.test.evil.example with credentials, and impl/wrong.py's .*example\.test accepts any origin that contains example.test. If you need a pattern, escape every dot and end it with $; a list of literal origins avoids the problem.
- With the default always_send=True, a request without an Origin header still gets Access-Control-Allow-Origin set to the first listed origin and Access-Control-Allow-Credentials: true. Browsers ignore it, but always_send=False keeps non-CORS responses free of CORS headers.
- flask-cors adds Vary: Origin only when it echoes an origin and the list has more than one entry or a regex, never to a refused origin or a request without Origin. A cache that stores such a response can replay it to a listed front end, so the fixed code adds Vary: Origin to every API response in an after_request hook registered before CORS(), which therefore runs after flask-cors's own.
- flask-cors only adds response headers in an after_request hook. A request from a refused origin still runs the view and gets a normal response, which the browser withholds from the page, and a refused preflight gets Flask's automatic 200 OPTIONS response with an Allow header and no CORS headers. Authorization stays in the view.
- Use flask-cors 6.0 or later: 6.0.0 fixed path-matching flaws (CVE-2024-6839, CVE-2024-6844 and CVE-2024-6866) that could apply one resource's CORS options to another path.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs cors-misconfiguration/python-flask 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 'session=...' https://your.api/api/invoices, and expect neither Access-Control-Allow-Origin nor Access-Control-Allow-Credentials; 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').
- Grep for supports_credentials, CORS_ORIGINS and regex characters in origin lists: Semgrep only reports an explicit origins="*", and CodeQL does not model flask-cors.
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 | python.flask.security.audit.flask-cors-misconfiguration.flask-cors-misconfiguration | No findings on impl/*.py. The rule matches only an explicit origins="*" next to supports_credentials=True, in CORS(), @cross_origin() or a resources dictionary. impl/vulnerable.py relies on the default origins, which reflects every origin in exactly the same way, and is not reported. On a probe with CORS(app, supports_credentials=True, origins="*") it reported the call.Checked: Semgrep 1.180.0 (CE), semgrep-rules develop at 9bbf021 |
| CodeQL | py/cors-misconfiguration-with-credentials | Not expected to report anything on this card. It is an experimental query that reports a CORSMiddleware whose origins list contains "*" or "null" with credentials allowed, and the only CORS middleware the Python library models is Starlette's and FastAPI's (semmle/python/frameworks/Starlette.qll and FastApi.qll). flask-cors is not modelled.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | py/cors-bypass | Not applicable: an experimental query that tracks remote input into str.startswith() calls on variables named like origin or cors, a hand-written prefix check. This card validates origins only through flask-cors, and the query has no model of its regex matching.Checked: github/codeql main at b5b8165; not executed |
| Bandit | n/aNo CORS check | None of Bandit's 75 tests concerns CORS or the Origin header, and it reported nothing on app.py or impl/.Checked: Bandit 1.9.4 |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| flask-cors | 6.0.5 |
| werkzeug | 3.1.9 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the cors-misconfiguration/python-flaskfolder.
python3.14 -m venv .venv
.venv/bin/python -m pip install --require-hashes -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs cors-misconfiguration/python-flask runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- pip 26.2.1
- flask 3.1.3
- flaskCors 6.0.5
- werkzeug 3.1.9
- pytest 9.1.1
- Matrix
- fixed: 13 of 13 tests pass; vulnerable: 9 of 13 tests fail (8 exploit tests); wrong: 6 of 13 tests fail (5 exploit tests)
- Test results
- fixed 13/13 pass; vulnerable 9/13 fail (all 8 exploit tests and the Vary test); wrong 6/13 fail (5 exploit tests and the Vary test), offline after install
- Dependency freshness
- requirements.txt pins all 14 packages with sha256 hashes for every file PyPI publishes; each hash set was checked against PyPI on 2026-10-10. flask-cors 6.0.5 was published on 2026-06-08. Werkzeug 3.1.9 (2026-09-27) is taken at 13 days old because it fixes GHSA-g6x2-hccm-hh4m. OSV listed no advisories for any pinned version on 2026-10-10. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
- Notes
- Header behaviour checked with probe apps on flask-cors 6.0.5: CORS(app, supports_credentials=True) echoed https://evil.example and null with credentials and Vary: Origin; adding send_wildcard=True raised ValueError at setup; the fixed list sent no CORS headers to a refused origin and answered a refused preflight with 200 and Allow only; with always_send left on, a request without Origin got Access-Control-Allow-Origin: https://admin.example.test and credentials; a single literal origin got no Vary: Origin.
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
- Flask-CORS documentation: the CORS extension (origins, supports_credentials, send_wildcard, vary_header)
- Flask-CORS 6.0.5 source: flask_cors/core.py (origin matching, always_send, Vary)
- Flask-CORS releases (6.0.0 security fixes)
- WHATWG Fetch Standard: CORS protocol and the CORS check
- MDN: Cross-Origin Resource Sharing (CORS)
- OWASP HTML5 Security Cheat Sheet: Cross Origin Resource Sharing
- PortSwigger Web Security Academy: CORS