Explorar el Código

Fixes #23502: Nonce every script NetBox renders

Add nonce="{{ csp_nonce }}" to every <script> in base.html, both token edit
pages and GraphiQL, so NetBox runs under a nonce-based policy without
'unsafe-inline'. Replace the four onerror attributes on core static assets
with one capture-phase error listener, placed first in <head>, that
redirects to the media failure page for assets marked data-media-failure.
A regression test asserts every <script> carries the response's nonce and
that no element has an inline event handler attribute.
Jason Novinger hace 10 horas
padre
commit
233792b49d

+ 96 - 0
netbox/netbox/tests/test_csp.py

@@ -0,0 +1,96 @@
+import re
+from html.parser import HTMLParser
+
+from django.conf import settings
+from django.test import Client, override_settings
+from django.urls import reverse
+from django.utils.csp import CSP
+
+from dcim.models import Site
+from utilities.testing import TestCase
+
+CSP_POLICY = {
+    'script-src': [CSP.SELF, CSP.NONCE],
+}
+
+
+class ScriptCollector(HTMLParser):
+    """
+    Record the attributes of every <script> element, and every inline event handler attribute (e.g. onerror) on any
+    element.
+    """
+    def __init__(self):
+        super().__init__()
+        self.scripts = []
+        self.handlers = []
+
+    def handle_starttag(self, tag, attrs):
+        attrs = dict(attrs)
+        if tag == 'script':
+            self.scripts.append(attrs)
+        for name in attrs:
+            # Inline event handler attributes are the only standard attributes starting with "on"
+            if name.startswith('on'):
+                self.handlers.append(f'<{tag} {name}="{attrs[name]}">')
+
+
+@override_settings(SECURE_CSP=CSP_POLICY, DEBUG=False)
+class ScriptNonceTestCase(TestCase):
+    """
+    Every <script> NetBox renders carries the request's CSP nonce, and no page uses inline event handler attributes
+    (which a nonce cannot cover). Together these let NetBox run under a policy without 'unsafe-inline'.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site 1', slug='site-1')
+
+    def setUp(self):
+        super().setUp()
+
+        self.user.is_superuser = True
+        self.user.save()
+
+    @staticmethod
+    def collect(response):
+        parser = ScriptCollector()
+        parser.feed(response.content.decode())
+        return parser
+
+    def assertScriptsNonced(self, response):
+        self.assertHttpStatus(response, 200)
+        match = re.search(r"'nonce-([^']+)'", response['Content-Security-Policy'])
+        self.assertIsNotNone(match, f"No nonce in header: {response['Content-Security-Policy']}")
+        nonce = match.group(1)
+
+        parser = self.collect(response)
+        self.assertTrue(parser.scripts, 'No <script> elements found')
+        for script in parser.scripts:
+            self.assertEqual(script.get('nonce'), nonce, f'<script> without the request nonce: {script}')
+        self.assertEqual(parser.handlers, [], 'Inline event handler attributes found')
+
+    def test_login(self):
+        self.assertScriptsNonced(Client().get(reverse('login')))
+
+    def test_object_list(self):
+        self.assertScriptsNonced(self.client.get(reverse('dcim:site_list')))
+
+    def test_object_detail(self):
+        self.assertScriptsNonced(self.client.get(self.site.get_absolute_url()))
+
+    def test_user_token_edit(self):
+        self.assertScriptsNonced(self.client.get(reverse('account:usertoken_add')))
+
+    def test_token_edit(self):
+        self.assertScriptsNonced(self.client.get(reverse('users:token_add')))
+
+    def test_copilot_script(self):
+        # Copilot is enabled globally by default. Each user opts in with a preference.
+        self.user.config.set('ui.copilot_enabled', 'true', commit=True)
+
+        response = self.client.get(reverse('dcim:site_list'))
+        self.assertScriptsNonced(response)
+        self.assertIn(settings.NETBOX_COPILOT_URL, [script.get('src') for script in self.collect(response).scripts])
+
+    def test_graphiql(self):
+        self.assertScriptsNonced(self.client.get(reverse('graphql'), HTTP_ACCEPT='text/html'))

+ 3 - 6
netbox/netbox/tests/test_middleware.py

@@ -261,9 +261,6 @@ class LoginRequiredMiddlewareTestCase(TestCase):
 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):
@@ -276,7 +273,7 @@ class ContentSecurityPolicyTestCase(TestCase):
         self.assertIsNotNone(match, f'No nonce in {header}: {response[header]}')
         return match.group(1)
 
-    @override_settings(SECURE_CSP=CSP_POLICY, DEBUG=True)
+    @override_settings(SECURE_CSP=CSP_POLICY)
     def test_policy_sends_header_with_nonce(self):
         response = self.anonymous_client.get(reverse('login'))
         self.assertHttpStatus(response, 200)
@@ -284,13 +281,13 @@ class ContentSecurityPolicyTestCase(TestCase):
         nonce = self.get_nonce(response)
         self.assertIn(f'nonce="{nonce}"', response.content.decode())
 
-    @override_settings(SECURE_CSP=CSP_POLICY, DEBUG=True)
+    @override_settings(SECURE_CSP=CSP_POLICY)
     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)
+    @override_settings(SECURE_CSP_REPORT_ONLY=CSP_POLICY)
     def test_report_only_policy(self):
         response = self.anonymous_client.get(reverse('login'))
         self.assertHttpStatus(response, 200)

+ 1 - 1
netbox/templates/account/usertoken_edit.html

@@ -8,7 +8,7 @@
 
 {% block javascript %}
 {{ block.super }}
-<script>
+<script nonce="{{ csp_nonce }}">
   (function () {
     const versionField = document.getElementById('id_version');
     const v1Warning = document.getElementById('v1-deprecation-warning');

+ 18 - 7
netbox/templates/base/base.html

@@ -24,19 +24,29 @@
     {# Page title #}
     <title>{% block title %}{% trans "Home" %}{% endblock %} | NetBox</title>
 
+    {# Redirect to the media failure page if a core static asset fails to load. Must precede those assets. #}
+    <script type="text/javascript" nonce="{{ csp_nonce }}">
+      window.addEventListener('error', function (event) {
+        if (event.target instanceof Element && event.target.dataset.mediaFailure) {
+          window.location = '{% url 'media_failure' %}?filename=' + event.target.dataset.mediaFailure;
+        }
+      }, true);
+    </script>
+
     {# Initialize color mode and sidebar folding #}
     <script
       type="text/javascript"
       src="{% static_with_params 'setmode.js' v=settings.RELEASE.version %}"
-      onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
+      data-media-failure="setmode.js"
+      nonce="{{ csp_nonce }}">
     </script>
-    <script type="text/javascript">
+    <script type="text/javascript" nonce="{{ csp_nonce }}">
       (function () {
         initMode()
       })();
       window.CSRF_TOKEN = "{{ csrf_token }}";
     </script>
-    <script type="text/javascript">
+    <script type="text/javascript" nonce="{{ csp_nonce }}">
       initSidebar();
     </script>
 
@@ -44,12 +54,12 @@
     <link
       rel="stylesheet"
       href="{% static_with_params 'netbox-external.css' v=settings.RELEASE.version %}"
-      onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
+      data-media-failure="netbox-external.css"
     />
     <link
       rel="stylesheet"
       href="{% static_with_params 'netbox.css' v=settings.RELEASE.version %}"
-      onerror="window.location='{% url 'media_failure' %}?filename=netbox.css'"
+      data-media-failure="netbox.css"
     />
     <link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
     <link rel="apple-touch-icon" type="image/png" href="{% static 'netbox_touch-icon-180.png' %}" />
@@ -58,7 +68,8 @@
     <script
       type="text/javascript"
       src="{% static_with_params 'netbox.js' v=settings.RELEASE.version %}"
-      onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
+      data-media-failure="netbox.js"
+      nonce="{{ csp_nonce }}">
     </script>
     {% django_htmx_script %}
 
@@ -74,7 +85,7 @@
 
     {# Additional Javascript #}
     {% if copilot_enabled and request.user.is_authenticated %}
-      <script src="{{ settings.NETBOX_COPILOT_URL }}" defer></script>
+      <script src="{{ settings.NETBOX_COPILOT_URL }}" defer nonce="{{ csp_nonce }}"></script>
     {% endif %}
     {% block javascript %}{% endblock %}
 

+ 1 - 1
netbox/templates/graphql/graphiql.html

@@ -56,6 +56,6 @@
       data-graphql-worker="{% url 'graphiql_worker' name='graphql' %}"
       data-json-worker="{% url 'graphiql_worker' name='json' %}"
     >Loading...</div>
-    <script src="{% static_with_params 'graphiql/graphiql.js' v=settings.RELEASE.version %}"></script>
+    <script src="{% static_with_params 'graphiql/graphiql.js' v=settings.RELEASE.version %}" nonce="{{ csp_nonce }}"></script>
   </body>
 </html>

+ 1 - 1
netbox/templates/users/token_edit.html

@@ -20,7 +20,7 @@
 
 {% block javascript %}
 {{ block.super }}
-<script>
+<script nonce="{{ csp_nonce }}">
   (function () {
     const versionField = document.getElementById('id_version');
     const v1Warning = document.getElementById('v1-deprecation-warning');