Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix CSRF in Python and Django

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

In short

Keep django.middleware.csrf.CsrfViewMiddleware in MIDDLEWARE and do not exempt views: it checks the Origin (or, over HTTPS, the Referer) against the site and CSRF_TRUSTED_ORIGINS, and the token. Put {% csrf_token %} in every POST form and send X-CSRFToken from fetch(). Change state only in @require_POST views.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework Django 6.1.2 (CsrfViewMiddleware, sessions and django.contrib.auth)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. Search for @csrf_exempt, csrf_exempt( in urls.py, CSRF_TRUSTED_ORIGINS entries with wildcards, and views that change state on GET (request.GET reads, @require_http_methods that lists GET).
  2. Keep CsrfViewMiddleware in MIDDLEWARE, after SessionMiddleware and before the views; remove the exemptions.
  3. Render {% csrf_token %} inside every form that posts to your site. For fetch(), read the token from the page (or the csrftoken cookie) and send it in the X-CSRFToken header.
  4. Decorate state-changing views with @require_POST (or another unsafe method) so a link or image cannot reach them.
  5. Behind a proxy that terminates TLS, set SECURE_PROXY_SSL_HEADER only when the proxy always sets or strips that header, so is_secure() is true and the Origin check compares https:// with https://; do not paper over the failure with CSRF_TRUSTED_ORIGINS or exemptions.
  6. List only the other origins that really post to you in CSRF_TRUSTED_ORIGINS, and set CSRF_COOKIE_SECURE and SESSION_COOKIE_SECURE.
  7. Test with Client(enforce_csrf_checks=True): the default test client skips CSRF checks, so ordinary tests pass even when the protection is off.

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

Vulnerableaccount/impl/vulnerable.pyPython
@csrf_exempt  # added when a fetch() call from the account page kept getting 403
@require_http_methods(["GET", "POST"])  # GET for the link in the "confirm your new address" email
def change_email(request):
    if not request.user.is_authenticated:
        return HttpResponse("Sign in first", status=401, content_type="text/plain")
    email = request.POST.get("email") or request.GET.get("email", "")
    if not re.fullmatch(r"[^@\s]+@[^@\s]+", email):
        return HttpResponse("Enter a valid email address", status=400, content_type="text/plain")
    request.user.email = email
    request.user.save(update_fields=["email"])
    return redirect("/account")

With IMPL=vulnerable, 7 of 12 tests fail (7 exploit tests).

The fix

Fixedaccount/impl/fixed.pyPython
@require_POST  # a link cannot reach it; CsrfViewMiddleware checks every POST it lets through
def change_email(request):
    if not request.user.is_authenticated:
        return HttpResponse("Sign in first", status=401, content_type="text/plain")
    email = request.POST.get("email", "")
    if not re.fullmatch(r"[^@\s]+@[^@\s]+", email):
        return HttpResponse("Enter a valid email address", status=400, content_type="text/plain")
    request.user.email = email
    request.user.save(update_fields=["email"])
    return redirect("/account")

With IMPL=fixed, 12 of 12 tests pass.

The fix: CSRF settings

Fixedconfig/settings.pyPython
# CsrfViewMiddleware (in MIDDLEWARE above) checks every POST, PUT, PATCH and DELETE: the Origin
# header, or over HTTPS without one the Referer, must be this site or a trusted origin, and the
# csrfmiddlewaretoken field or the X-CSRFToken header must match the csrftoken cookie.
# List only other origins that really post to this site. "https://*.example.test" would trust
# every subdomain, including any that serve user content or that someone else can take over.
CSRF_TRUSTED_ORIGINS: list[str] = []
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# Lax is the default: defence in depth, not the CSRF defence. Browsers still send it on
# top-level GET navigations and on requests from sibling subdomains.
SESSION_COOKIE_SAMESITE = "Lax"
# Behind a proxy that terminates TLS, tell Django the request was HTTPS, or every Origin check
# fails ("https://..." against "http://..."). Only if the proxy always sets or strips the header:
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fixaccount/impl/wrong.pyPython
def same_site_referer(view):
    @wraps(view)
    def check(request, *args, **kwargs):
        referer = request.headers.get("Referer")
        # Some browsers and privacy tools strip the Referer, so allow requests without one.
        if request.method != "GET" and referer and request.get_host() not in referer:
            return HttpResponseForbidden("Cross-site request refused")
        return view(request, *args, **kwargs)

    return check


@csrf_exempt  # Django's check broke fetch(); ours accepts it
@same_site_referer
@require_http_methods(["GET", "POST"])  # the session cookie is SameSite=Lax, so GET is "safe"
def change_email(request):
    if not request.user.is_authenticated:
        return HttpResponse("Sign in first", status=401, content_type="text/plain")
    email = request.POST.get("email") or request.GET.get("email", "")
    if not re.fullmatch(r"[^@\s]+@[^@\s]+", email):
        return HttpResponse("Enter a valid email address", status=400, content_type="text/plain")
    request.user.email = email
    request.user.save(update_fields=["email"])
    return redirect("/account")

With IMPL=wrong, 3 of 12 tests fail (3 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_csrf.pyPython
# Exploit: each forged request carries Alice's session cookie. Browsers withhold a
# SameSite=Lax cookie from cross-site POSTs, but not from sibling subdomains or top-level
# GET navigations, and older browsers ignore SameSite, so the server must refuse anyway.

def forge(browser, headers, fields=None):
    return browser.post("/account/email", headers, {**(fields or {}), "email": "mallory@evil.example"})


def test_exploit_cross_site_form_post_without_a_token_is_refused(sign_in, accounts):
    alice = sign_in("alice")
    assert_refused(accounts, forge(alice, form_from(EVIL)))


def test_exploit_foreign_origin_is_refused_when_the_browser_sends_no_fetch_metadata(sign_in, accounts):
    alice = sign_in("alice")
    assert_refused(accounts, forge(alice, {"Origin": EVIL, "Referer": f"{EVIL}/"}))


def test_exploit_sibling_subdomain_is_refused_although_samesite_cookies_reach_it(sign_in, accounts):
    alice = sign_in("alice")
    assert_refused(accounts, forge(alice, form_from(SIBLING, "same-site")))


def test_exploit_token_from_the_attackers_own_session_is_refused(sign_in, accounts):
    bobs_fields = hidden_fields(sign_in("bob").account_page())
    alice = sign_in("alice")
    assert_refused(accounts, forge(alice, form_from(EVIL), bobs_fields))


def test_exploit_referer_that_merely_contains_the_host_name_is_refused(sign_in, accounts):
    alice = sign_in("alice")
    assert_refused(accounts, forge(alice, form_from(LOOKALIKE)))


def test_exploit_page_that_suppresses_the_referer_is_refused(sign_in, accounts):
    # <meta name="referrer" content="no-referrer"> on the attacking page: no Referer, Origin: null.
    alice = sign_in("alice")
    headers = {"Origin": "null", "Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document"}
    assert_refused(accounts, forge(alice, headers))


def test_exploit_cross_site_get_navigation_cannot_change_the_email(sign_in, accounts):
    # Lax cookies are sent on top-level GET navigations, so only the method protects this.
    alice = sign_in("alice")
    res = alice.get("/account/email?email=mallory%40evil.example", link_from(EVIL))
    assert email_of("alice") == "alice@example.test", f"a GET changed Alice's email (HTTP {res.status_code})"
    assert res.status_code >= 400

Pitfalls

  • The default test client disables CSRF checks. A test suite built on it stays green when you add @csrf_exempt or remove the middleware; the regression tests here use enforce_csrf_checks=True and send real Origin and Referer headers over HTTPS.
  • @csrf_exempt is usually added because fetch() or an API client got a 403. Send the X-CSRFToken header instead, or move token-authenticated API endpoints to a separate URL space without cookie sessions.
  • Over plain HTTP Django checks the Origin but never the Referer, so a POST without Origin is checked by the token alone; run the site over HTTPS (SECURE_SSL_REDIRECT) so the strict Referer check applies too.
  • CSRF_TRUSTED_ORIGINS = ["https://*.example.test"] trusts every subdomain, and CSRF_COOKIE_DOMAIN = ".example.test" lets every subdomain post and set the token cookie; a forum or user-content host on a subdomain then becomes an attack site.
  • Django rotates the CSRF secret when a user signs in, so a form loaded before sign-in fails afterwards with 403; reload it rather than exempting the view.
  • CSRF_COOKIE_HTTPONLY = True adds no protection (Django's docs say so) and breaks scripts that read the cookie; read the token from the page instead.
  • SameSite=Lax on the session cookie is defence in depth, not the fix: browsers send it on top-level GET navigations and on requests from sibling subdomains, which is exactly what the wrong implementation relies on.

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 csrf/python-django does this for you).
  2. Against your own site, sign in, then from another origin post the form without a token, with Origin: null and with a look-alike host in the Origin and Referer; each must get 403 and leave the data unchanged. Follow a cross-site link to the action URL and expect 405.
  3. grep for csrf_exempt and run Semgrep's no-csrf-exempt rule; it found the exemption in both failing implementations here, but not the state-changing GET.

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 CSRF in Python and Django
ToolRuleWhat it found here
Semgreppython.django.security.audit.csrf-exempt.no-csrf-exempt1 finding in vulnerable.py and 1 in wrong.py (the @csrf_exempt decorator), none in fixed.py. It is in p/security-audit, which found nothing else; p/django (28 rules) and p/owasp-top-ten found nothing. It cannot tell the wrong fix's own Referer check from no check, and it does not see views that change state on GET.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> account config (Ran 1 rule)
CodeQLpy/csrf-protection-disabledNot expected to flag this card. The query reports a Django settings file whose MIDDLEWARE lacks CsrfViewMiddleware; Django.qll models @csrf_exempt only as a local setting that can excuse a project, and nothing reports a single exempt view. It also skips files whose path contains 'test'.Checked: github/codeql main at b5b8165; not executed
Banditn/aNo CSRF pluginNo findings: Bandit has no check for csrf_exempt or for state-changing GET views.Checked: Bandit 1.9.4; bandit -r account config

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkDjango 6.1.2 (CsrfViewMiddleware, sessions and django.contrib.auth)
django6.1.2
pytest9.1.1
pytest-django4.14.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/python-djangofolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -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 csrf/python-django runs all three modes and checks the expected results.

Download the project ZIP, 24 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python CPython 3.14.8
  • django 6.1.2
  • pytest 9.1.1
  • pytest-django 4.14.0
  • semgrep 1.180.0
  • bandit 1.9.4
Matrix
fixed: 12 of 12 tests pass; vulnerable: 7 of 12 tests fail (7 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
Test results
fixed 12/12 pass; vulnerable 7/12 fail (all 7 exploit tests); wrong 3/12 fail (3 exploit tests), offline after install
Dependency freshness
Django 6.1.2 was published on 2026-10-06 and is taken at four days old because it is a security release (CVE-2026-77050, CVE-2026-84429, CVE-2026-87890); pytest-django 4.14.0 dates from 2026-08-10. iniconfig is pinned at 2.3.0 rather than 2.3.1 (2026-10-06), which was four days old and is not a security release. OSV reported no known vulnerabilities for any pin. The tests switch to Django's MD5 password hasher only to keep sign-in fast, as Django's testing docs suggest; the settings keep the default PBKDF2 hasher. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.

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. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. Django 6.1: Cross Site Request Forgery protection (how it works, limitations)
  3. Django 6.1: How to use Django's CSRF protection (AJAX and X-CSRFToken)
  4. Django 6.1 settings: CSRF_TRUSTED_ORIGINS
  5. Django 6.1 settings: SECURE_PROXY_SSL_HEADER
  6. Django 6.1 testing tools: enforce_csrf_checks
  7. Django 6.1: View decorators (require_POST)
  8. CWE-352: Cross-Site Request Forgery (CSRF)

Attack patterns:

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.