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.
- 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).
- Keep CsrfViewMiddleware in MIDDLEWARE, after SessionMiddleware and before the views; remove the exemptions.
- 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.
- Decorate state-changing views with @require_POST (or another unsafe method) so a link or image cannot reach them.
- 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.
- List only the other origins that really post to you in CSRF_TRUSTED_ORIGINS, and set CSRF_COOKIE_SECURE and SESSION_COOKIE_SECURE.
- Test with Client(enforce_csrf_checks=True): the default test client skips CSRF checks, so ordinary tests pass even when the protection is off.
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
@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
@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
# 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
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 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 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- 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.
- 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).
- 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.
- 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.
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.django.security.audit.csrf-exempt.no-csrf-exempt | 1 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) |
| CodeQL | py/csrf-protection-disabled | Not 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 |
| Bandit | n/aNo CSRF plugin | No findings: Bandit has no check for csrf_exempt or for state-changing GET views.Checked: Bandit 1.9.4; bandit -r account config |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Django 6.1.2 (CsrfViewMiddleware, sessions and django.contrib.auth) |
| django | 6.1.2 |
| pytest | 9.1.1 |
| pytest-django | 4.14.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/python-djangofolder.
python3 -m venv .venv
.venv/bin/python -m pip install -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 csrf/python-django runs all three modes and checks the expected results.
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
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet
- Django 6.1: Cross Site Request Forgery protection (how it works, limitations)
- Django 6.1: How to use Django's CSRF protection (AJAX and X-CSRFToken)
- Django 6.1 settings: CSRF_TRUSTED_ORIGINS
- Django 6.1 settings: SECURE_PROXY_SSL_HEADER
- Django 6.1 testing tools: enforce_csrf_checks
- Django 6.1: View decorators (require_POST)
- CWE-352: Cross-Site Request Forgery (CSRF)
Attack patterns: