Skip to content
CWE-352A01:2021 – Broken Access Control

Django

CsrfViewMiddleware, CSRF_TRUSTED_ORIGINS, the csrf_exempt trap, and why Django REST Framework's TokenAuthentication skips the check entirely.

The rule

Keep CsrfViewMiddleware enabled, keep @csrf_exempt out of the codebase, and understand what Django REST Framework does differently.

Django's CSRF protection is among the most complete of any framework, and it is on by default. It does three things most others do not:

  • Masks the token per render to defeat BREACH.
  • Checks Origin against CSRF_TRUSTED_ORIGINS since 4.0, with a Referer fallback on HTTPS. This is a real header check, not just a token.
  • Rejects requests with no Referer on HTTPS when Origin is absent, rather than failing open.

So an out-of-the-box Django application is well defended. Almost every Django CSRF finding in practice comes from something the team added: an exemption, a DRF authentication class, or a widened CSRF_TRUSTED_ORIGINS.

Configuration

Pythonsettings.pyVulnerable
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    # CsrfViewMiddleware removed "because the API doesn't need it".
    # It is a global removal -- every server-rendered view lost protection
    # too, which is almost never what was intended.
    'django.contrib.auth.middleware.AuthenticationMiddleware',
]

# A wildcard subdomain entry. Any subdomain -- including one taken over via
# a dangling CNAME -- is now a trusted origin.
CSRF_TRUSTED_ORIGINS = ['https://*.example.com']

# Readable by JavaScript on any subdomain, and scoped to the parent domain
# so any subdomain can overwrite it.
CSRF_COOKIE_HTTPONLY = False
CSRF_COOKIE_DOMAIN = '.example.com'
SESSION_COOKIE_SAMESITE = None       # note: the string None, not Lax

Templates and AJAX

HTMLaccount.htmlSecure
<!-- One tag. It renders a hidden input containing a freshly-masked token.
     The mask changes on every render, so two views of the same page show
     different values -- that is BREACH mitigation, not a bug. -->
<form method="post" action="{% url 'change_email' %}">
  {% csrf_token %}
  <input type="email" name="email" required>
  <button type="submit">Update</button>
</form>

Django REST Framework: the important one

Pythonthe DRF trapVulnerable
# This is the single most common source of real CSRF findings in Django
# applications, and it is not obvious from reading the code.
#
# DRF's SessionAuthentication ENFORCES CSRF -- it explicitly calls the check.
# But DRF's other authentication classes DO NOT, because they are designed
# for credentials that are not ambient.
#
# The bug appears when a class that skips CSRF is combined with a credential
# that IS ambient.

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',   # enforces CSRF
        'rest_framework.authentication.TokenAuthentication',     # does NOT
    ],
}

# DRF tries each class in order and stops at the first that authenticates.
# That is fine as written -- TokenAuthentication uses an Authorization
# header, which is not ambient, so skipping CSRF is correct for it.
#
# The danger is a CUSTOM authentication class that reads an ambient
# credential without enforcing CSRF:

class CookieTokenAuthentication(BaseAuthentication):
    def authenticate(self, request):
        # Reads a COOKIE -- ambient, sent automatically by the browser --
        # but inherits no CSRF enforcement. Every endpoint using this is
        # CSRF-able, and nothing in the code says so.
        token = request.COOKIES.get('auth_token')
        if not token:
            return None
        return (User.objects.get(auth_token=token), None)

    # The fix: implement enforce_csrf, as SessionAuthentication does.
    # def enforce_csrf(self, request):
    #     check = CSRFCheck(lambda r: None)
    #     check.process_request(request)
    #     reason = check.process_view(request, None, (), {})
    #     if reason:
    #         raise exceptions.PermissionDenied(f'CSRF Failed: {reason}')

# Also worth knowing: overriding SessionAuthentication to remove
# enforce_csrf() is a documented "fix" in several blog posts and Stack
# Overflow answers for making a SPA work. It disables CSRF protection
# entirely. Grep for `def enforce_csrf` returning None.

The exemption trap

Pythonviews.pyVulnerable
from django.views.decorators.csrf import csrf_exempt

# Almost always added to make something work during development -- a webhook,
# a mobile client sending the wrong header, an integration test -- and then
# never removed. Every one of these is a route with no CSRF protection.
@csrf_exempt
def webhook(request):
    # If this is a genuine third-party webhook, the exemption is legitimate --
    # but then it MUST authenticate by signature, not by session cookie.
    process(json.loads(request.body))
    return JsonResponse({'ok': True})

@csrf_exempt              # <- no justification at all
def change_email(request):
    request.user.email = request.POST['email']
    request.user.save()
    return JsonResponse({'ok': True})

# Audit with:
#   grep -rn 'csrf_exempt\|ensure_csrf_cookie\|requires_csrf_token' .
#
# Every hit needs a comment saying why. A webhook exempted from CSRF and
# authenticated by an HMAC signature is correct; anything authenticated by
# a session cookie is a finding.

Checklist

  • CsrfViewMiddleware present in MIDDLEWARE, globally, not removed for an API.
  • grep -rn csrf_exempt returns nothing without a written justification.
  • CSRF_TRUSTED_ORIGINS contains exact origins, no wildcards.
  • CSRF_USE_SESSIONS = True where a decoupled frontend does not need to read the token.
  • If a cookie is used: CSRF_COOKIE_SECURE = True, CSRF_COOKIE_DOMAIN = None, SameSite=Lax, and a __Host- prefixed name.
  • SESSION_COOKIE_SAMESITE = 'Lax' set explicitly rather than left to the browser.
  • No custom DRF authentication class reads a cookie without implementing enforce_csrf.
  • No override of SessionAuthentication.enforce_csrf that returns without checking.
  • Views check request.method — a view that acts on GET is unprotected by design, since Django exempts GET, HEAD, OPTIONS, and TRACE.
  • Re-authentication required for password change, email change, and adding a payment method.