Chapter 7 · Security

7.2 The CSRF check: CsrfViewMiddleware.process_view

CsrfViewMiddleware.process_view is the CSRF check: before a view runs, it decides whether a request of a method that is not safe came from the site’s own pages, first by its "Origin" or "Referer" header and then by a token that must match the browser’s CSRF secret. A request it refuses is answered by the view that CSRF_FAILURE_VIEW names, csrf_failure by default, which returns a 403.

The check is a sequence of tests, and the first that decides ends it. Four let a request through without looking at it; the rest ask where it came from and what it carries. Each refusal has a reason, a short sentence that is logged and handed to the failure view. An accepted request goes on to its view; a refused one gets the failure view’s response in its place. The secret and its token are in The CSRF secret and its token.

Figure 7.3 The decisions of CsrfViewMiddleware.process_view, from the top. The first test that decides ends the check.

Requests let through unchecked

A request already checked is not checked again. CsrfViewMiddleware._accept marks an accepted request with the attribute "csrf_processing_done", and process_view returns at once for a request so marked, which is what lets the middleware and the csrf_protect decorator share one request (A middleware as a decorator).

A view marked by csrf_exempt is let through next, without the request being marked (View decorators). It is tested after CsrfViewMiddleware.process_request has read the browser’s secret, so that, a comment says, get_token still works in an exempt view.

Then the safe methods, "GET", "HEAD", "OPTIONS" and "TRACE", on the comment’s assumption that whatever RFC 9110 does not define as safe needs protection. Last, the test client’s flag: its handler sets "_dont_enforce_csrf_checks" on every request, true unless the client was made with enforce_csrf_checks=True (The test client: Client and ClientHandler). Both these paths mark the request as checked.

The origin

A request with an "Origin" header is refused unless CsrfViewMiddleware._origin_verified accepts it, in one of three ways tried in order. The header may equal the site’s own origin: "https" or "http" as HttpRequest.is_secure says, and the host from HttpRequest.get_host (Headers, host and scheme), a host refused by ALLOWED_HOSTS raising DisallowedHost, which is passed over. It may be one of CSRF_TRUSTED_ORIGINS exactly. Or it may have the scheme of a trusted origin written with a "*", and a host that matches that origin’s host, star taken off, by is_same_domain, where a pattern that begins with a dot matches the domain and every subdomain.

The setting is read into three cached properties, once for each instance of the middleware. In a run, a client whose chain had refused an origin went on refusing it after CSRF_TRUSTED_ORIGINS was changed to name it; a new client accepted it.

Only a request with no "Origin" has its "Referer" read, and only if it is secure. The comment’s reason: an attacker in the middle of a plain-HTTP visit can supply a cookie and a token of their own, the secret being tied to no session, and post them to the HTTPS site, which must treat its plain-HTTP self as untrusted; and as Barth et al. found the "Referer" missing from same-domain HTTPS requests in about 0.2% of cases or fewer, a strict check is possible.

CsrfViewMiddleware._check_referer refuses a request with no "Referer", one that does not parse into a scheme and a host, and one whose scheme is not "https". It accepts a host that matches a trusted origin’s host by is_same_domain, whatever that origin’s scheme. Otherwise the host must match the cookie’s domain, CSRF_COOKIE_DOMAIN or, with CSRF_USE_SESSIONS, SESSION_COOKIE_DOMAIN, with the request’s port added unless it is 443 or 80; or, when that domain is None, as by default, get_host, port and all, a DisallowedHost being refused.

With CSRF_TRUSTED_ORIGINS = ["https://*.harbour.example", "https://pay.example"] added to the harbour’s, a run of the example, with the test client making the requests, each with the cookie and the token, gave:

POST https://harbour.example/berths/
  Origin: https://pilots.harbour.example   200  Berth requested.
  Origin: http://pilots.harbour.example    403  Forbidden (Origin checking failed - http://pilots.harbour.example does not match any trusted origins.): /berths/
  Origin: https://a.pay.example            403  Forbidden (Origin checking failed - https://a.pay.example does not match any trusted origins.): /berths/
  Referer: https://harbour.example:8443/berths/
                                           403  Forbidden (Referer checking failed - https://harbour.example:8443/berths/ does not match any trusted origins.): /berths/

The token

CsrfViewMiddleware._check_token reads the secret again from the cookie or the session, by CsrfViewMiddleware._get_secret, rather than taking the copy process_request kept in META. The comment’s reason: an authentication middleware may have called rotate_token in its process_request, replacing that copy, while the browser’s token was made from the old secret. A malformed cookie is refused, though process_request has already replaced it for the response. No secret is refused too: insisting on a cookie for a "POST", the comment says, avoids all CSRF attacks, login CSRF among them.

For a "POST" the token is the form field "csrfmiddlewaretoken". An UnreadablePostError while the body is read is swallowed, as process_view should not raise, the comment says, and the field counts as empty. If the field is missing or empty, or the method is another, the token is the header CSRF_HEADER_NAME names, "HTTP_X_CSRFTOKEN" by default, which is X-CSRFToken: a fallback, the comment says, to make things easier for AJAX and possible for "PUT" and "DELETE". No other body is read: in a run, a "PUT" whose form-encoded body held a good token was refused with "CSRF token missing.".

The token must then be 32 or 64 letters and digits. _does_token_match unmasks a token of 64, takes one of 32 as the secret itself, as a script that read the cookie sends it, and compares it with the secret by constant_time_compare (Secret keys and django.utils.crypto). CsrfViewMiddleware._bad_token_message names a failing token’s source: "POST", or "the 'X-Csrftoken' HTTP header", as HttpHeaders.parse_header_name spells it.

Refusal and its reasons

The referer and token tests raise RejectRequest with their reason, and every refusal ends in CsrfViewMiddleware._reject. It imports the view CSRF_FAILURE_VIEW names by get_callable, calls it with the request and the reason, and logs the response by log_response to "django.security.csrf", as "Forbidden (%s): %s" with the reason and the path. log_response logs a 4xx as a warning and marks the response as logged, so the handler, which logs every response of 400 or more to "django.request", does not log it again.

A comment says _accept and _reject exist as methods for requires_csrf_token, whose class replaces _reject with one that refuses nothing (A middleware as a decorator). The security checks confirm that the failure view can be imported and takes a request and a reason (The security checks).

Table 7.4 The reasons the CSRF check gives, in the order it tests. The messages for a malformed cookie or token are made from REASON_INCORRECT_LENGTH and REASON_INVALID_CHARACTERS.
reason refused when message
REASON_BAD_ORIGIN refused when the "Origin" is not verified message "Origin checking failed - %s does not match any trusted origins."
REASON_NO_REFERER refused when secure, and no "Origin" or "Referer" message "Referer checking failed - no Referer."
REASON_MALFORMED_REFERER refused when the "Referer" does not parse into a scheme and a host message "Referer checking failed - Referer is malformed."
REASON_INSECURE_REFERER refused when the "Referer" is not HTTPS message "Referer checking failed - Referer is insecure while host is secure."
REASON_BAD_REFERER refused when the host is neither the site’s nor trusted message "Referer checking failed - %s does not match any trusted origins."
a malformed cookie refused when its length or characters message "CSRF cookie has incorrect length.", "CSRF cookie has invalid characters."
REASON_NO_CSRF_COOKIE refused when no secret message "CSRF cookie not set."
REASON_CSRF_TOKEN_MISSING refused when no token message "CSRF token missing."
a malformed token refused when its length or characters message "CSRF token from POST has incorrect length."
a wrong token refused when it does not match the secret message "CSRF token from POST incorrect."

The failure view: csrf_failure

csrf_failure builds its page’s context: a translated title and sentence; the reason; "no_referer" and "no_cookie", true when the reason is REASON_NO_REFERER or REASON_NO_CSRF_COOKIE, each with paragraphs for the user; settings.DEBUG, with the documentation’s version for its links and a line for when it is off; and the nonce, merged in from the csp context processor.

It asks the project’s template engines for "403_csrf.html". A project’s template of that name is rendered with the request alone and none of that context: in a run, one that wrote {{ reason }}, {{ no_cookie }} and {{ csp_nonce }} rendered the first two empty and the third, from the context processor, with the nonce. When no engine has it, csrf_failure renders the built-in "csrf_403.html" with an Engine of its own and the context, so the page does not depend on TEMPLATES. A template name of a caller’s own that does not exist raises TemplateDoesNotExist.

The built-in page shows the paragraphs for a missing "Referer" or cookie when the reason is one of those, and, with settings.DEBUG true, the reason and a list of what to check. Its stylesheet carries {% csp_nonce_attr %}, so the harbour’s 403 made the nonce, and its policy named it, though only in "script-src", where the harbour’s setting puts it. The response is an HttpResponseForbidden.

CsrfViewMiddleware sets async_capable = False, so under ASGI it runs as a synchronous layer. The check is synchronous for another reason: process_view is a synchronous method, which the handler adapts to its own mode separately from the layer (Sync and async in one chain).