Chapter 7 · Security

7.3 The security headers: SecurityMiddleware and XFrameOptionsMiddleware

SecurityMiddleware can redirect a request made over plain HTTP to HTTPS, and adds Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy and Cross-Origin-Opener-Policy to responses. XFrameOptionsMiddleware adds X-Frame-Options, and the decorators xframe_options_deny, xframe_options_sameorigin and xframe_options_exempt change it for one view.

Each of these headers is an instruction to the browser: reach this site only over HTTPS, do not guess a content type, say little in a "Referer", keep this window apart from other sites’ windows, do not show this page in another site’s frame. Django adds the header and the browser does the refusing. SecurityMiddleware acts in process_request and process_response, XFrameOptionsMiddleware in process_response alone, and neither replaces a header the response already has, so a view that sets one keeps its own. Both declare async_capable = False, so under ASGI they run synchronously (Sync and async in one chain).

When the settings are read

SecurityMiddleware.__init__ copies its settings into attributes, and compiles each pattern of SECURE_REDIRECT_EXEMPT as a regular expression. It runs once, when the handler builds the chain (Building the middleware chain), so a setting changed later in the process is not seen by that chain. XFrameOptionsMiddleware keeps no setting: it reads X_FRAME_OPTIONS at each response. A run of the example shows the difference, with the test client making the requests and three settings changed by override_settings after one client’s first request:

settings changed in the running process
    SECURE_HSTS_PRELOAD = True
    SECURE_REFERRER_POLICY = "no-referrer, strict-origin-when-cross-origin"
    X_FRAME_OPTIONS = "sameorigin"
GET https://harbour.example/berths/
  from a client whose chain was built before the change
    Strict-Transport-Security: max-age=3600
    Referrer-Policy: same-origin
    X-Frame-Options: SAMEORIGIN
  from a client made after it
    Strict-Transport-Security: max-age=3600; preload
    Referrer-Policy: no-referrer,strict-origin-when-cross-origin
    X-Frame-Options: SAMEORIGIN

The redirect to HTTPS

SecurityMiddleware.process_request answers a request itself when three things hold: SECURE_SSL_REDIRECT is true, the request is not secure, and no pattern of SECURE_REDIRECT_EXEMPT is found in its path. The answer is a 301, an HttpResponsePermanentRedirect, to "https://", the host, and HttpRequest.get_full_path, which keeps the query string. The host is SECURE_SSL_HOST if it names one, and otherwise HttpRequest.get_host, which raises DisallowedHost for a host outside ALLOWED_HOSTS: in a run of the example, a plain request for evil.example got a 400 and no redirect.

The method is not looked at, and no URL has been resolved yet, so a path with no view is redirected too. Whether the request is secure is HttpRequest.is_secure’s answer, which a proxy’s header can decide through SECURE_PROXY_SSL_HEADER (Headers, host and scheme).

The exemptions are matched with a search, not from the start, against the path with its leading slashes taken off. So r"^health/$" exempts /health/ but not /x/health/ or /healthy/, r"^/health/$" can never match, and r"health" exempts every path that contains the word.

MiddlewareMixin.__call__ passes the redirect to the same middleware’s process_response, so it leaves with this middleware’s headers, though without Strict-Transport-Security, since the request was not secure; the middleware inside it never run (Security shows such a response).

The headers on the way out

SecurityMiddleware.process_response adds up to four headers, in this order.

Strict-Transport-Security goes only on a response to a secure request, only when SECURE_HSTS_SECONDS is not zero, and only when the response has no such header. Its value is "max-age=" and the seconds, then "; includeSubDomains" if SECURE_HSTS_INCLUDE_SUBDOMAINS is true and "; preload" if SECURE_HSTS_PRELOAD is.

X-Content-Type-Options is "nosniff" when SECURE_CONTENT_TYPE_NOSNIFF is true. The documentation’s reason: a browser that guesses a file’s type over its Content-Type may take an uploaded file for HTML or JavaScript.

Referrer-Policy comes from SECURE_REFERRER_POLICY, which may be a string of comma-separated values, split and stripped, or an iterable; either way the values are joined with a bare comma. The comment gives the reason as fallback: the documentation says the browser takes the last value it understands. A policy of "no-referrer" leaves a secure request with no "Origin" without the "Referer" that the CSRF check then requires (The CSRF check: CsrfViewMiddleware.process_view).

Cross-Origin-Opener-Policy comes from SECURE_CROSS_ORIGIN_OPENER_POLICY; the documentation describes it as isolating the site’s windows from other sites’ documents, against attacks like Spectre.

Strict-Transport-Security is tested for and then assigned. The other three are added with setdefault, the last through HttpResponseBase.setdefault, which hands it to ResponseHeaders.setdefault: a header is set only when the response lacks it. The effect is the same in all four.

Table 7.5 The two middleware’s settings and their defaults (django/conf/global_settings.py). With none set, only the headers of the last four rows are added.
setting default at the default
SECURE_SSL_REDIRECT default False at the default no redirect
SECURE_SSL_HOST default None at the default the request’s own host
SECURE_REDIRECT_EXEMPT default [] at the default no path exempt
SECURE_HSTS_SECONDS default 0 at the default no Strict-Transport-Security
SECURE_HSTS_INCLUDE_SUBDOMAINS default False at the default no "includeSubDomains"
SECURE_HSTS_PRELOAD default False at the default no "preload"
SECURE_CONTENT_TYPE_NOSNIFF default True at the default "nosniff"
SECURE_REFERRER_POLICY default "same-origin" at the default "same-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY default "same-origin" at the default "same-origin"
X_FRAME_OPTIONS default "DENY" at the default "DENY"

XFrameOptionsMiddleware

X-Frame-Options says whether a browser may show the response inside a frame: "DENY" nowhere, "SAMEORIGIN" only in a page of the same site, as the class’s docstring says. XFrameOptionsMiddleware.process_response leaves the response alone if it has the header already, or if it carries an attribute "xframe_options_exempt" that is true. Otherwise it asks XFrameOptionsMiddleware.get_xframe_options_value, given the request and the response, which returns X_FRAME_OPTIONS in capitals, so "sameorigin" goes out as "SAMEORIGIN". Its docstring invites a subclass to override it, so that the value can depend on the request or the response.

The decorators

xframe_options_deny and xframe_options_sameorigin set the header to "DENY" or "SAMEORIGIN" on the response of the view they wrap, unless it already has one; xframe_options_exempt sets the attribute "xframe_options_exempt" to True. Each makes a coroutine function as its wrapper when the view is one and an ordinary function otherwise, so an async view stays async. What they share with Django’s other view decorators is in View decorators.

A decorator acts on the response as the view returns it, inside the whole chain and before any middleware’s process_response. So the header a decorator sets is already there when XFrameOptionsMiddleware looks, and the middleware leaves it; a header the view set itself is left by the decorator in turn. The harbour office’s tide table is framed by another site, and its chart only by its own pages. In a run of the example, with the first view marked xframe_options_exempt and the second xframe_options_sameorigin:

GET https://harbour.example/tides/
    no X-Frame-Options
GET https://harbour.example/chart/
    X-Frame-Options: SAMEORIGIN