skip to content

In Django 6.x, how do you trial a stricter Content Security Policy in report-only mode first, and what does Django leave for you to build?

level: middleimportance: should knowfreq 30%

answer

  1. the second setting
  2. both headers at once
  3. a reporting directive
  4. no built-in receiver

basics

~10 s

Put the candidate policy, including a report-uri directive, in SECURE_CSP_REPORT_ONLY while SECURE_CSP keeps the current one. Django sends both headers but has no endpoint for violation reports; you write that view.

solid answer

~40 s

`SECURE_CSP_REPORT_ONLY` renders the `Content-Security-Policy-Report-Only` header: the browser allows everything but reports what the policy would have blocked. It can run alongside `SECURE_CSP`, so I keep the enforced baseline and trial the stricter candidate next to it; both headers share the request's nonce if they use `CSP.NONCE`. Reports are only sent when the dict includes a reporting directive, for example `"report-uri": "/csp-report/"`, and Django ships no receiver: I write a small POST view, exempt it from CSRF because browsers send no token, and log or store what arrives. When the reports are clean, the candidate dict moves into `SECURE_CSP`. `csp_report_only_override` lets a single view trial or drop its own candidate.

code

python · 14 lines
python
from django.utils.csp import CSP

# Current policy stays enforced.
SECURE_CSP = {
    "default-src": [CSP.SELF],
    "script-src": [CSP.SELF, CSP.UNSAFE_INLINE],
}

# Stricter candidate, reported only.
SECURE_CSP_REPORT_ONLY = {
    "default-src": [CSP.SELF],
    "script-src": [CSP.SELF, CSP.NONCE],
    "report-uri": "/csp-report/",
}

go deeper

for a junior

Recall that SECURE_CSP_REPORT_ONLY sends a header that reports without blocking and that it needs a report-uri entry to send reports anywhere.

for a middle

Explain running both settings at once, the shared nonce, and what a minimal csrf_exempt receiver view must do.

for a senior

Plan the promotion from report-only to enforced, harden the public report endpoint, and use per-view report-only overrides for new pages.

for a principal

Decide who owns the report stream and the promotion criteria, and whether reports go to an in-house endpoint or an external collector.

## Why report-only first Turning on an enforced policy for a live site risks blocking something real: a tag manager, an embedded form, an inline style nobody remembered. The CSP standard's answer is the **report-only** header, `Content-Security-Policy-Report-Only`: the browser evaluates the policy, lets everything load, and reports what it would have blocked. Django 6.x exposes it as its own setting, so a rollout is a sequence of settings changes. ## The two settings side by side | Stage | `SECURE_CSP` | `SECURE_CSP_REPORT_ONLY` | |---|---|---| | discovery | `{}` | the candidate policy plus a reporting directive | | tightening an existing policy | the current, enforced policy | the stricter candidate | | steady state | the verified policy | optionally the same policy, to keep reports flowing | The settings are independent: each non-empty dict produces its own header, and an empty dict produces none. When both use `CSP.NONCE`, `ContentSecurityPolicyMiddleware` substitutes the **same** per-request nonce into both headers, so templates need one attribute, not two. ## Reports need a directive and a receiver Two things are easy to miss: 1. **The policy must ask for reports.** Browsers send violation reports only when the policy contains a reporting directive such as `report-uri`. Without one, violations appear in the browser console and nowhere else. In Django the directive is just another dict entry: `"report-uri": "/csp-report/"`. 2. **Django does not receive them.** The documentation says so explicitly: there is no built-in view, model or admin for reports. You write the endpoint or point the directive at an external collector. A minimal receiver in Django: - accepts `POST` only (`require_POST`); - is decorated with `csrf_exempt`, because the browser's report request carries no CSRF token and `CsrfViewMiddleware` would reject it with a 403; - reads `request.body` defensively (size limit, JSON errors ignored), because anyone can post to it; - logs or counts the report and returns `204`. ```python import json import logging from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST logger = logging.getLogger("csp") @csrf_exempt @require_POST def csp_report(request): if len(request.body) <= 10_000: try: logger.warning("csp violation: %s", json.loads(request.body)) except ValueError: pass return HttpResponse(status=204) ``` ## Routing the receiver The path in `report-uri` must resolve to the view, so the receiver gets an ordinary URLconf entry, for example `path("csp-report/", views.csp_report, name="csp-report")`. Three practical points apply to it: - keep it outside any login requirement, because browsers send reports without the user's intent and sometimes without cookies; - keep it cheap: logging or incrementing a counter, never a synchronous call to another service; - if the site runs `LoginRequiredMiddleware` (Django 5.1+), mark the view with `login_not_required` so reports are not redirected to the login page. ## Promoting the policy Once the reports show only noise, the switch is a settings change: move the candidate dict into `SECURE_CSP`, and either empty `SECURE_CSP_REPORT_ONLY` or keep the same policy there to keep receiving reports about regressions. Because both are plain dictionaries, a common pattern is to build the candidate from the current policy in Python and diff them in review. ## Per-view trials `django.views.decorators.csp.csp_report_only_override(config)` replaces the report-only policy for one view, and `csp_report_only_override({})` removes that header for the view. It is useful when one page, for example a new landing page, needs its own candidate policy before the site-wide one changes. ## Limits of report-only - It **protects nobody**: nothing is blocked while the policy is report-only. - Reports come from real browsers, including extensions and injected content, so the stream contains noise; separating that noise from real breakage is policy practice rather than Django configuration. - The report endpoint is public and unauthenticated by nature; treat its input as untrusted data and keep it cheap.

  • Why does the report endpoint need csrf_exempt?
    Browsers send violation reports as a plain POST with no CSRF token and no form, so `CsrfViewMiddleware` would reject every one with a 403. Exempting that single view is safe because it changes nothing important; it only records untrusted input, which it must still validate and size-limit.
  • Can the report-only and enforced headers use different nonces?
    No, and they do not need to. The middleware holds one lazy nonce per request and substitutes it into every header whose policy contains `CSP.NONCE`, so a single `nonce` attribute in the template satisfies both policies.

saying these in an interview costs you the question

  • Report-only mode blocks attacks while you gather reports.
  • Django collects CSP reports automatically and shows them in the admin.
  • Violations are reported even when the policy has no reporting directive.
  • You cannot send the enforced and report-only headers together.
  • The report endpoint needs the CSRF token like any other POST view.