From fb0b0a1b17040237d8eca6c266bdfda75a4b93b1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jul 2026 17:17:29 -0400 Subject: [PATCH] Fixes #22746: Sanitize rendered HTTP headers for outbound webhooks --- netbox/extras/models/models.py | 15 ++++++-- netbox/extras/tests/test_event_rules.py | 49 ++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 9cd778dbe..b44e107e6 100644 --- a/netbox/extras/models/models.py +++ b/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): diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py index 2764652d8..d4259b476 100644 --- a/netbox/extras/tests/test_event_rules.py +++ b/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]')