Fixes #22746: Sanitize rendered HTTP headers for outbound webhooks
This commit is contained in:
parent
3e3d36cc2d
commit
fb0b0a1b17
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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]')
|
||||
|
|
|
|||
Loading…
Reference in New Issue