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

Fixes #22746: Sanitize rendered HTTP headers for outbound webhooks

Jeremy Stretch 1 месяц назад
Родитель
Сommit
fb0b0a1b17
2 измененных файлов с 60 добавлено и 4 удалено
  1. 12 3
      netbox/extras/models/models.py
  2. 48 1
      netbox/extras/tests/test_event_rules.py

+ 12 - 3
netbox/extras/models/models.py

@@ -1,4 +1,5 @@
 import json
+import re
 import urllib.parse
 from pathlib import Path
 
@@ -284,10 +285,18 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, OwnerMixin, Ch
         if not self.additional_headers:
             return {}
         ret = {}
-        data = render_jinja2(self.additional_headers, context)
-        for line in data.splitlines():
+        for line in self.additional_headers.splitlines():
+            if not line.strip():
+                continue
             header, value = line.split(':', 1)
-            ret[header.strip()] = value.strip()
+            # Render each header name & value independently so that interpolated context data (which may contain
+            # newlines or other control characters) cannot introduce additional headers via CR/LF injection.
+            header = render_jinja2(header, context)
+            value = render_jinja2(value, context)
+            # Strip any control characters (including CR/LF & null bytes) that survive rendering
+            header = re.sub(r'[\x00-\x1f\x7f]', '', header).strip()
+            value = re.sub(r'[\x00-\x1f\x7f]', '', value).strip()
+            ret[header] = value
         return ret
 
     def render_body(self, context):

+ 48 - 1
netbox/extras/tests/test_event_rules.py

@@ -8,7 +8,7 @@ from unittest.mock import Mock, patch
 import django_rq
 from django.conf import settings
 from django.http import HttpResponse
-from django.test import RequestFactory, tag
+from django.test import RequestFactory, TestCase, tag
 from django.urls import reverse
 from PIL import Image
 from requests import Session
@@ -801,3 +801,50 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
         job = self.queue.jobs[0]
         self.assertEqual(job.kwargs['event_rule'], event_rule)
         self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
+
+
+class WebhookRenderHeadersTest(TestCase):
+
+    def test_render_headers(self):
+        """Basic header rendering with Jinja2 interpolation."""
+        webhook = Webhook(
+            name='Webhook 1',
+            payload_url='http://localhost:9000/',
+            additional_headers='X-Foo: Bar\nX-Object: {{ data.name }}',
+        )
+        headers = webhook.render_headers({'data': {'name': 'Site 1'}})
+        self.assertEqual(headers, {'X-Foo': 'Bar', 'X-Object': 'Site 1'})
+
+    def test_render_headers_strips_control_characters(self):
+        """
+        Control characters (including null bytes) in a rendered header value must be stripped to
+        prevent header injection via crafted context data.
+        """
+        webhook = Webhook(
+            name='Webhook 1',
+            payload_url='http://localhost:9000/',
+            additional_headers='X-Object: {{ data.name }}',
+        )
+
+        # A value smuggling a null byte and a carriage return must be sanitized in place
+        headers = webhook.render_headers({'data': {'name': 'foo\x00\rbar'}})
+        self.assertEqual(headers, {'X-Object': 'foobar'})
+
+    def test_render_headers_crlf_injection_is_neutralized(self):
+        """
+        A newline embedded in an interpolated value must NOT be able to introduce an additional header
+        (CR/LF injection). The value is rendered in isolation, so the newline is stripped in place.
+        """
+        webhook = Webhook(
+            name='Webhook 1',
+            payload_url='http://localhost:9000/',
+            additional_headers='X-Object: {{ data.name }}',
+        )
+        headers = webhook.render_headers({'data': {'name': 'legit\r\nX-Injected: evil'}})
+
+        # Exactly one header is produced; no smuggled X-Injected header, no residual control characters
+        self.assertEqual(list(headers.keys()), ['X-Object'])
+        self.assertNotIn('X-Injected', headers)
+        for name, value in headers.items():
+            self.assertNotRegex(name, r'[\x00-\x1f\x7f]')
+            self.assertNotRegex(value, r'[\x00-\x1f\x7f]')