Browse Source

Fixes #23502: Add SECURE_CSP and SECURE_CSP_REPORT_ONLY configuration parameters

Pass both through to Django's settings of the same names, enable Django's
ContentSecurityPolicyMiddleware directly after SecurityMiddleware, and add
the csp context processor so templates can read the per-request nonce as
csp_nonce. Both parameters default to an empty dict, which sends no header.
Jason Novinger 11 hours ago
parent
commit
9daeb8e1c5
3 changed files with 98 additions and 0 deletions
  1. 27 0
      docs/configuration/security.md
  2. 4 0
      netbox/netbox/settings.py
  3. 67 0
      netbox/netbox/tests/test_middleware.py

+ 27 - 0
docs/configuration/security.md

@@ -200,6 +200,33 @@ The view name or URL to which a user is redirected after logging out.
 
 ---
 
+## SECURE_CSP
+
+Default: `{}`
+
+Defines a Content Security Policy (CSP) to be sent with every response in the `Content-Security-Policy` header. This is a pass-through to Django's [`SECURE_CSP`](https://docs.djangoproject.com/en/stable/ref/settings/#secure-csp) setting: directives are mapped to lists of sources, using the constants in `django.utils.csp.CSP`. Include `CSP.NONCE` in a directive to have a fresh nonce generated for each request, which is added to the header whenever the rendered page uses it. If empty (the default), no header is sent.
+
+```python
+from django.utils.csp import CSP
+
+SECURE_CSP = {
+    'default-src': [CSP.SELF],
+    'script-src': [CSP.SELF, CSP.NONCE],
+}
+```
+
+Templates can read the per-request nonce as `csp_nonce`.
+
+---
+
+## SECURE_CSP_REPORT_ONLY
+
+Default: `{}`
+
+Defines a Content Security Policy to be sent in the `Content-Security-Policy-Report-Only` header. The browser reports violations of this policy without blocking anything, which makes it useful for testing a policy before enforcing it with [`SECURE_CSP`](#secure_csp). This is a pass-through to Django's [`SECURE_CSP_REPORT_ONLY`](https://docs.djangoproject.com/en/stable/ref/settings/#secure-csp-report-only) setting, and takes the same form as `SECURE_CSP`. If empty (the default), no header is sent.
+
+---
+
 ## SECURE_HSTS_INCLUDE_SUBDOMAINS
 
 Default: `False`

+ 4 - 0
netbox/netbox/settings.py

@@ -205,6 +205,8 @@ RQ_RETRY_MAX = param('RQ_RETRY_MAX', 0, types=int, min_value=0)
 SCRIPTS_ROOT = param('SCRIPTS_ROOT', os.path.join(NETBOX_ROOT, 'scripts'), types=str).rstrip('/')
 SEARCH_BACKEND = param('SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend', types=str)
 SECRET_KEY = param('SECRET_KEY', types=str)  # Required
+SECURE_CSP = param('SECURE_CSP', {}, types=dict)
+SECURE_CSP_REPORT_ONLY = param('SECURE_CSP_REPORT_ONLY', {}, types=dict)
 SECURE_HSTS_INCLUDE_SUBDOMAINS = param('SECURE_HSTS_INCLUDE_SUBDOMAINS', False, types=bool)
 SECURE_HSTS_PRELOAD = param('SECURE_HSTS_PRELOAD', False, types=bool)
 SECURE_HSTS_SECONDS = param('SECURE_HSTS_SECONDS', 0, types=int, min_value=0)
@@ -529,6 +531,7 @@ MIDDLEWARE = [
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'django.middleware.security.SecurityMiddleware',
+    'django.middleware.csp.ContentSecurityPolicyMiddleware',
     'django_htmx.middleware.HtmxMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.LoginRequiredMiddleware',
@@ -570,6 +573,7 @@ TEMPLATES = [
                 'django.template.context_processors.debug',
                 'django.template.context_processors.request',
                 'django.template.context_processors.media',
+                'django.template.context_processors.csp',
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
                 'netbox.context_processors.settings',

+ 67 - 0
netbox/netbox/tests/test_middleware.py

@@ -1,4 +1,5 @@
 import json
+import re
 from contextlib import contextmanager
 from types import SimpleNamespace
 from unittest import skipIf
@@ -10,6 +11,7 @@ from django.core.signals import got_request_exception
 from django.db.utils import InternalError
 from django.test import Client, RequestFactory, override_settings
 from django.urls import ResolverMatch, reverse
+from django.utils.csp import CSP
 from django.views.generic import View
 from rest_framework import status
 from rest_framework.views import APIView
@@ -17,6 +19,11 @@ from rest_framework.views import APIView
 from netbox.middleware import CoreMiddleware, LoginRequiredMiddleware, MaintenanceModeMiddleware
 from utilities.testing import TestCase
 
+CSP_POLICY = {
+    'default-src': [CSP.SELF],
+    'script-src': [CSP.SELF, CSP.NONCE],
+}
+
 
 class CoreMiddlewareTestCase(TestCase):
 
@@ -249,3 +256,63 @@ class LoginRequiredMiddlewareTestCase(TestCase):
     def test_plugin_views_login_not_required(self):
         response = self.anonymous_client.get(reverse('plugins:dummy_plugin:dummy_model_list'))
         self.assertHttpStatus(response, 200)
+
+
+class ContentSecurityPolicyTestCase(TestCase):
+    """
+    SECURE_CSP and SECURE_CSP_REPORT_ONLY are passed through to Django's CSP middleware.
+
+    Django adds the nonce to the header only when the rendered page reads csp_nonce. The nonce
+    tests set DEBUG=True because {% django_htmx_script %} in base.html then does.
+    """
+
+    def setUp(self):
+        super().setUp()
+
+        self.anonymous_client = Client()
+
+    def get_nonce(self, response, header='Content-Security-Policy'):
+        match = re.search(r"'nonce-([^']+)'", response[header])
+        self.assertIsNotNone(match, f'No nonce in {header}: {response[header]}')
+        return match.group(1)
+
+    @override_settings(SECURE_CSP=CSP_POLICY, DEBUG=True)
+    def test_policy_sends_header_with_nonce(self):
+        response = self.anonymous_client.get(reverse('login'))
+        self.assertHttpStatus(response, 200)
+        self.assertIn("default-src 'self'", response['Content-Security-Policy'])
+        nonce = self.get_nonce(response)
+        self.assertIn(f'nonce="{nonce}"', response.content.decode())
+
+    @override_settings(SECURE_CSP=CSP_POLICY, DEBUG=True)
+    def test_nonce_differs_between_requests(self):
+        nonce1 = self.get_nonce(self.anonymous_client.get(reverse('login')))
+        nonce2 = self.get_nonce(self.anonymous_client.get(reverse('login')))
+        self.assertNotEqual(nonce1, nonce2)
+
+    @override_settings(SECURE_CSP_REPORT_ONLY=CSP_POLICY, DEBUG=True)
+    def test_report_only_policy(self):
+        response = self.anonymous_client.get(reverse('login'))
+        self.assertHttpStatus(response, 200)
+        self.assertNotIn('Content-Security-Policy', response)
+        nonce = self.get_nonce(response, header='Content-Security-Policy-Report-Only')
+        self.assertIn(f'nonce="{nonce}"', response.content.decode())
+
+    def test_no_header_by_default(self):
+        response = self.anonymous_client.get(reverse('login'))
+        self.assertHttpStatus(response, 200)
+        self.assertNotIn('Content-Security-Policy', response)
+        self.assertNotIn('Content-Security-Policy-Report-Only', response)
+
+    @override_settings(SECURE_CSP=CSP_POLICY, LOGIN_REQUIRED=True, DEBUG=False)
+    def test_header_on_non_html_responses(self):
+        requests = (
+            (reverse('home'), 302),
+            (reverse('api-root'), 403),
+            ('/no-such-page/', 404),
+        )
+        for url, status_code in requests:
+            with self.subTest(url=url):
+                response = self.anonymous_client.get(url)
+                self.assertHttpStatus(response, status_code)
+                self.assertIn("default-src 'self'", response['Content-Security-Policy'])