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?
answer
- the second setting
- both headers at once
- a reporting directive
- no built-in receiver
basics
~10 sPut 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 linesfrom 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
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.
Explain running both settings at once, the shared nonce, and what a minimal csrf_exempt receiver view must do.
Plan the promotion from report-only to enforced, harden the public report endpoint, and use per-view report-only overrides for new pages.
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.