Просмотр исходного кода

Fixes #23502: Remove htmx runtime eval from global search and the object selector

Global search's 3-character minimum moves out of the keyup[...] trigger
filter into a keyup listener that dispatches netbox:search-input once the
input reaches the length given by data-search-min-length. The generic object
selector's stale-ID guard moves from hx-on::config-request to a
data-htmx-clear-param attribute and one document-level htmx:configRequest
listener. Both keep working under a CSP without 'unsafe-eval'. htmx's
allowEval is left at its default on purpose, as the comment in htmx.ts says.
Jason Novinger 1 день назад
Родитель
Сommit
82a35e3f44

+ 1 - 1
netbox/ipam/tests/test_views.py

@@ -724,7 +724,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         data = {
         data = {
             'pk': [prefix.pk],
             'pk': [prefix.pk],
             'scope_content_type': ContentType.objects.get_for_model(Site).pk,
             'scope_content_type': ContentType.objects.get_for_model(Site).pk,
-            # The client-side hx-on::config-request clears the paired object id on a type change.
+            # The client-side data-htmx-clear-param listener clears the paired object id on a type change.
             'scope_object_id': '',
             'scope_object_id': '',
         }
         }
         response = self.client.post(self._get_url('bulk_edit'), data, headers={'HX-Request': 'true'})
         response = self.client.post(self._get_url('bulk_edit'), data, headers={'HX-Request': 'true'})

+ 3 - 1
netbox/netbox/forms/search.py

@@ -22,7 +22,9 @@ class SearchForm(forms.Form):
             attrs={
             attrs={
                 'hx-get': '',
                 'hx-get': '',
                 'hx-target': '#object_list',
                 'hx-target': '#object_list',
-                'hx-trigger': 'keyup[target.value.length >= 3] changed delay:500ms',
+                # netbox:search-input is dispatched by htmx.ts once the value reaches data-search-min-length
+                'hx-trigger': 'netbox:search-input changed delay:500ms',
+                'data-search-min-length': '3',
             }
             }
         )
         )
     )
     )

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

@@ -34,6 +34,20 @@ class ScriptCollector(HTMLParser):
                 self.handlers.append(f'<{tag} {name}="{attrs[name]}">')
                 self.handlers.append(f'<{tag} {name}="{attrs[name]}">')
 
 
 
 
+class HxTriggerCollector(HTMLParser):
+    """
+    Record the attributes of every <input> element that has an hx-trigger attribute.
+    """
+    def __init__(self):
+        super().__init__()
+        self.inputs = []
+
+    def handle_starttag(self, tag, attrs):
+        attrs = dict(attrs)
+        if tag == 'input' and 'hx-trigger' in attrs:
+            self.inputs.append(attrs)
+
+
 @override_settings(SECURE_CSP=CSP_POLICY, DEBUG=False)
 @override_settings(SECURE_CSP=CSP_POLICY, DEBUG=False)
 class ScriptNonceTestCase(TestCase):
 class ScriptNonceTestCase(TestCase):
     """
     """
@@ -94,3 +108,15 @@ class ScriptNonceTestCase(TestCase):
 
 
     def test_graphiql(self):
     def test_graphiql(self):
         self.assertScriptsNonced(self.client.get(reverse('graphql'), HTTP_ACCEPT='text/html'))
         self.assertScriptsNonced(self.client.get(reverse('graphql'), HTTP_ACCEPT='text/html'))
+
+    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).
+        response = self.client.get(reverse('search'))
+        self.assertHttpStatus(response, 200)
+        collector = HxTriggerCollector()
+        collector.feed(response.content.decode())
+        self.assertEqual(len(collector.inputs), 1)
+        attrs = collector.inputs[0]
+        self.assertEqual(attrs['hx-trigger'], 'netbox:search-input changed delay:500ms')
+        self.assertEqual(attrs['data-search-min-length'], '3')

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 5 - 0
netbox/project-static/src/global.d.ts

@@ -96,5 +96,10 @@ interface HTMLElementEventMap {
     parameters: Record<string, string>;
     parameters: Record<string, string>;
     formData: FormData;
     formData: FormData;
     headers: Record<string, string>;
     headers: Record<string, string>;
+    elt: Element;
   }>;
   }>;
 }
 }
+
+interface DocumentEventMap {
+  'htmx:configRequest': HTMLElementEventMap['htmx:configRequest'];
+}

+ 35 - 0
netbox/project-static/src/htmx.ts

@@ -45,11 +45,46 @@ function revealSubmissionErrors(event: Event): void {
   }
   }
 }
 }
 
 
+/**
+ * Dispatch `netbox:search-input` on an input once its value reaches the length in
+ * `data-search-min-length`. The input's `hx-trigger` listens for this event instead of using a
+ * `keyup[...]` filter, which htmx compiles with runtime eval.
+ */
+function dispatchSearchInput(event: KeyboardEvent): void {
+  const input = event.target;
+  if (!(input instanceof HTMLInputElement)) return;
+  const rawMinLength = input.dataset.searchMinLength;
+  if (!rawMinLength) return;
+  const minLength = Number(rawMinLength);
+  if (Number.isNaN(minLength)) return;
+  if (input.value.length >= minLength) {
+    input.dispatchEvent(new CustomEvent('netbox:search-input', { bubbles: true }));
+  }
+}
+
+/**
+ * Blank the request parameter named by `data-htmx-clear-param` on the requesting element. The
+ * generic object selector uses this so a stale object ID can't cross content types. It replaces
+ * an `hx-on::config-request` attribute, which htmx evaluates at runtime.
+ */
+function clearConfiguredParam(event: HTMLElementEventMap['htmx:configRequest']): void {
+  const { elt, parameters } = event.detail;
+  const param = elt.getAttribute('data-htmx-clear-param');
+  if (param) {
+    parameters[param] = '';
+  }
+}
+
 /**
 /**
  * Hook into HTMX's event system to reinitialize specific native event listeners when HTMX swaps
  * Hook into HTMX's event system to reinitialize specific native event listeners when HTMX swaps
  * elements.
  * elements.
  */
  */
 export function initHtmx(): void {
 export function initHtmx(): void {
+  // htmx.config.allowEval stays at its default: a CSP without 'unsafe-eval' already blocks eval,
+  // and disabling it would break plugins that use hx-on or trigger filters. See #23503.
+  document.addEventListener('keyup', dispatchSearchInput);
+  document.addEventListener('htmx:configRequest', clearConfiguredParam);
+
   document.addEventListener('htmx:afterSettle', event => {
   document.addEventListener('htmx:afterSettle', event => {
     initDepedencies();
     initDepedencies();
     revealSubmissionErrors(event);
     revealSubmissionErrors(event);

+ 2 - 3
netbox/utilities/forms/fields/generic.py

@@ -195,9 +195,8 @@ class GenericObjectChoiceField(forms.MultiValueField):
 
 
         # Clear the paired object selection client-side when the content type changes, so a stale object_id
         # Clear the paired object selection client-side when the content type changes, so a stale object_id
         # cannot cross model boundaries on the HTMX re-render (a Site pk must not resurface as a Region pk).
         # cannot cross model boundaries on the HTMX re-render (a Site pk must not resurface as a Region pk).
-        self.content_type_field.widget.attrs['hx-on::config-request'] = (
-            f"event.detail.parameters['{field_name}_object_id'] = ''"
-        )
+        # The htmx:configRequest listener in htmx.ts blanks the parameter named here.
+        self.content_type_field.widget.attrs['data-htmx-clear-param'] = f'{field_name}_object_id'
 
 
         content_type_value = self._resolve_subvalue(form, field_name, 'content_type')
         content_type_value = self._resolve_subvalue(form, field_name, 'content_type')
         object_value = self._resolve_subvalue(form, field_name, 'object_id')
         object_value = self._resolve_subvalue(form, field_name, 'object_id')

+ 4 - 4
netbox/utilities/tests/test_forms.py

@@ -909,10 +909,10 @@ class GenericObjectChoiceFieldTestCase(TestCase):
         form = self._make_form(initial={'obj': self.site})
         form = self._make_form(initial={'obj': self.site})
         field = form.fields['obj']
         field = form.fields['obj']
         field.get_bound_field(form, 'obj')
         field.get_bound_field(form, 'obj')
-        self.assertEqual(
-            field.content_type_field.widget.attrs.get('hx-on::config-request'),
-            "event.detail.parameters['obj_object_id'] = ''",
-        )
+        attrs = field.content_type_field.widget.attrs
+        self.assertEqual(attrs.get('data-htmx-clear-param'), 'obj_object_id')
+        # The clearing is done by a listener in the static bundle, not by htmx runtime eval.
+        self.assertNotIn('hx-on::config-request', attrs)
 
 
     def test_content_type_lookup_is_cached(self):
     def test_content_type_lookup_is_cached(self):
         """Repeated content-type resolution hits the database only once per field instance."""
         """Repeated content-type resolution hits the database only once per field instance."""

Некоторые файлы не были показаны из-за большого количества измененных файлов