Procházet zdrojové kódy

Fixes #23502: Nonce the scripts on the Swagger, Redoc and browsable API pages

NetBox now overrides drf-spectacular's Swagger UI and Redoc templates and DRF's script block in the browsable API template so every script tag carries the request's CSP nonce. The overrides extend the upstream templates and change only the blocks that hold scripts. The CSP regression test covers all three pages.
Jason Novinger před 1 dnem
rodič
revize
6203fc1460

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

@@ -1,10 +1,13 @@
 import re
 from html.parser import HTMLParser
+from urllib.parse import urlsplit
 
 from django.conf import settings
+from django.contrib.staticfiles import finders
 from django.test import Client, override_settings
 from django.urls import reverse
 from django.utils.csp import CSP
+from drf_spectacular.settings import patched_settings
 
 from dcim.models import Site
 from utilities.testing import TestCase
@@ -109,6 +112,51 @@ class ScriptNonceTestCase(TestCase):
     def test_graphiql(self):
         self.assertScriptsNonced(self.client.get(reverse('graphql'), HTTP_ACCEPT='text/html'))
 
+    def test_swagger_ui(self):
+        self.assertScriptsNonced(self.client.get(reverse('api_docs')))
+
+    def test_redoc(self):
+        self.assertScriptsNonced(self.client.get(reverse('api_redocs')))
+
+    def test_redoc_with_settings(self):
+        # With REDOC_UI_SETTINGS set, Redoc renders an inline Redoc.init script. drf-spectacular reads its settings
+        # once at import and has no setting_changed hook, so override_settings(SPECTACULAR_SETTINGS=...) has no
+        # effect. patched_settings is its own supported way to override them temporarily.
+        with patched_settings({'REDOC_UI_SETTINGS': {'hideDownloadButton': True}}):
+            response = self.client.get(reverse('api_redocs'))
+        self.assertScriptsNonced(response)
+        inline = [script for script in self.collect(response).scripts if 'src' not in script]
+        self.assertEqual(len(inline), 1)
+        self.assertIn('Redoc.init', response.content.decode())
+
+    def test_browsable_api(self):
+        self.assertScriptsNonced(self.client.get(reverse('api-root'), HTTP_ACCEPT='text/html'))
+        self.assertScriptsNonced(self.client.get(reverse('dcim-api:site-list'), HTTP_ACCEPT='text/html'))
+
+    def test_api_script_sources_exist(self):
+        # The overrides copy upstream script tags, so an upgrade that renames a bundled file (e.g. jQuery) would
+        # leave a dead src. Every same-origin script src must resolve to a static file.
+        static_url = settings.STATIC_URL
+        responses = [
+            self.client.get(reverse('api_docs')),
+            self.client.get(reverse('api_redocs')),
+            self.client.get(reverse('api-root'), HTTP_ACCEPT='text/html'),
+        ]
+        for response in responses:
+            self.assertHttpStatus(response, 200)
+            checked = 0
+            for script in self.collect(response).scripts:
+                src = script.get('src')
+                if not src:
+                    continue
+                path = urlsplit(src).path
+                if not path.startswith(static_url):
+                    continue
+                checked += 1
+                relative = path.removeprefix(static_url)
+                self.assertTrue(finders.find(relative), f'No static file for script src {src}')
+            self.assertGreater(checked, 0, f'No same-origin scripts found on {response.request["PATH_INFO"]}')
+
     def test_search_has_no_runtime_eval(self):
         # A trigger filter such as keyup[...] is compiled with eval by htmx, which a policy without
         # 'unsafe-eval' blocks (htmx then fires with no filter at all).

+ 16 - 0
netbox/templates/drf_spectacular/redoc.html

@@ -0,0 +1,16 @@
+{# Extends the upstream drf_spectacular template of the same name. The body block copies upstream to add CSP nonces: re-sync it when drf-spectacular is upgraded. #}
+{% extends 'drf_spectacular/redoc.html' %}
+
+{% block body %}
+  {% if settings %}
+    <div id="redoc-container"></div>
+    <script src="{{ redoc_standalone }}" nonce="{{ csp_nonce }}"></script>
+    <script nonce="{{ csp_nonce }}">
+      const redocSettings = {{ settings|safe }};
+      Redoc.init("{{ schema_url }}", redocSettings, document.getElementById('redoc-container'))
+    </script>
+  {% else %}
+    <redoc spec-url="{{ schema_url }}"></redoc>
+    <script src="{{ redoc_standalone }}" nonce="{{ csp_nonce }}"></script>
+  {% endif %}
+{% endblock body %}

+ 15 - 0
netbox/templates/drf_spectacular/swagger_ui.html

@@ -0,0 +1,15 @@
+{# Extends the upstream drf_spectacular template of the same name. The body block copies upstream to add CSP nonces: re-sync it when drf-spectacular is upgraded. #}
+{% extends 'drf_spectacular/swagger_ui.html' %}
+
+{% block body %}
+  <div id="swagger-ui"></div>
+  <script src="{{ swagger_ui_bundle }}" nonce="{{ csp_nonce }}"></script>
+  <script src="{{ swagger_ui_standalone }}" nonce="{{ csp_nonce }}"></script>
+  {% if script_url %}
+    <script src="{{ script_url }}" nonce="{{ csp_nonce }}"></script>
+  {% else %}
+    <script nonce="{{ csp_nonce }}">
+      {% include template_name_js %}
+    </script>
+  {% endif %}
+{% endblock body %}

+ 17 - 0
netbox/templates/rest_framework/api.html

@@ -7,6 +7,23 @@
   <link rel="icon" type="image/png" href="{% static 'rest-api.ico' %}" />
 {% endblock head %}
 
+{% block script %}
+  {# Copies the upstream DRF script block to add CSP nonces: re-sync it when djangorestframework is upgraded. #}
+  <script type="application/json" id="drf_csrf" nonce="{{ csp_nonce }}">
+    {
+      "csrfHeaderName": "{{ csrf_header_name|default:'X-CSRFToken' }}",
+      "csrfToken": "{% if request %}{{ csrf_token }}{% endif %}"
+    }
+  </script>
+  <script src="{% static 'rest_framework/js/jquery-3.7.1.min.js' %}" nonce="{{ csp_nonce }}"></script>
+  <script src="{% static 'rest_framework/js/ajax-form.js' %}" nonce="{{ csp_nonce }}"></script>
+  <script src="{% static 'rest_framework/js/csrf.js' %}" nonce="{{ csp_nonce }}"></script>
+  <script src="{% static 'rest_framework/js/bootstrap.min.js' %}" nonce="{{ csp_nonce }}"></script>
+  <script src="{% static 'rest_framework/js/prettify-min.js' %}" nonce="{{ csp_nonce }}"></script>
+  <script src="{% static 'rest_framework/js/default.js' %}" nonce="{{ csp_nonce }}"></script>
+  <script src="{% static 'rest_framework/js/load-ajax-form.js' %}" nonce="{{ csp_nonce }}"></script>
+{% endblock script %}
+
 {% block title %}{% if name %}{{ name }} | {% endif %}NetBox {% trans "REST API" %}{% endblock %}
 
 {% block branding %}