Software Securityhome
Fix a findingSearch
Fix card · A02:2025 Security Misconfiguration

Fix CORS misconfiguration in Python and Flask

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

On this page

Steps

  1. Find every CORS(...) and @cross_origin(...) call, CORS_* configuration keys and after_request hooks that set Access-Control-Allow-Origin.
  2. Pass an explicit list of serialized origins (scheme, host and port) from configuration: CORS(app, resources={r"/api/*": {"origins": allowed_origins}}, supports_credentials=True, ...).
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.pyPython
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

Fixedimpl/fixed.pyPython
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

Wrong fiximpl/wrong.pyPython
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 regression test

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.

Testtests/test_cors.pyPython
# 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)})"

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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').
  3. 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.

Scanner rules

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.

Scanner rules for Fix CORS misconfiguration in Python and Flask
ToolRuleWhat it found here
Semgreppython.flask.security.audit.flask-cors-misconfiguration.flask-cors-misconfigurationNo 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
CodeQLpy/cors-misconfiguration-with-credentialsNot 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
CodeQLpy/cors-bypassNot 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
Banditn/aNo CORS checkNone 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
flask-cors6.0.5
werkzeug3.1.9
pytest9.1.1

Run the tests

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.

Shell
python3.14 -m venv .venv
.venv/bin/python -m pip install --require-hashes -r requirements.txt
Shell
# 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 pytest

Each 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.

Download the project ZIP, 26 KB

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

  1. Flask-CORS documentation: the CORS extension (origins, supports_credentials, send_wildcard, vary_header)
  2. Flask-CORS 6.0.5 source: flask_cors/core.py (origin matching, always_send, Vary)
  3. Flask-CORS releases (6.0.0 security fixes)
  4. WHATWG Fetch Standard: CORS protocol and the CORS check
  5. MDN: Cross-Origin Resource Sharing (CORS)
  6. OWASP HTML5 Security Cheat Sheet: Cross Origin Resource Sharing
  7. PortSwigger Web Security Academy: CORS

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.