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

Closes #22607: Sanitize HTTP requests passed to template contexts for custom links (#22616)

Jeremy Stretch 14 часов назад
Родитель
Сommit
df83277156

+ 5 - 2
docs/customization/custom-links.md

@@ -28,10 +28,13 @@ The following context data is available within the template when rendering a cus
 |-----------|-------------------------------------------------------------------------------------------------------------------|
 | `object`  | The NetBox object being displayed                                                                                 |
 | `debug`   | A boolean indicating whether debugging is enabled                                                                 |
-| `request` | The current WSGI request                                                                                          |
-| `user`    | The current user (if authenticated)                                                                               |
+| `request` | A sanitized subset of the current request (see below)                                                             |
+| `user`    | The current user (if authenticated)                                                                   |
 | `perms`   | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
 
+!!! note "Changed in NetBox v4.7"
+    For security, `request` no longer exposes the full WSGI request object. Only a safe subset of attributes is available: `request.id`, `request.path`, `request.path_info`, `request.method`, `request.GET` (the query parameters), and `request.user` (the username). Sensitive data such as cookies, headers, and session state is no longer accessible from within a custom link template.
+
 While most of the context variables listed above will have consistent attributes, the object will be an instance of the specific object being viewed when the link is rendered. Different models have different fields and properties, so you may need to some research to determine the attributes available for use within your template for a specific object type.
 
 Checking the REST API representation of an object is generally a convenient way to determine what attributes are available. You can also reference the NetBox source code directly for a comprehensive list.

+ 2 - 0
docs/integrations/webhooks.md

@@ -30,6 +30,8 @@ The following data is available as context for Jinja2 templates:
     * `request.id` - The UUID associated with the request
     * `request.method` - The HTTP method (e.g. `GET` or `POST`)
     * `request.path` - The URL path (ex: `/dcim/sites/123/edit/`)
+    * `request.path_info` - The URL path below the application script prefix
+    * `request.GET` - The query parameters included in the request
     * `request.user` - The name of the authenticated user who made the request (if available)
 * `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
 * `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.

+ 2 - 1
netbox/extras/templatetags/custom_links.py

@@ -5,6 +5,7 @@ from django.utils.safestring import mark_safe
 from core.models import ObjectType
 from extras.models import CustomLink
 from netbox.choices import ButtonColorChoices
+from utilities.request import get_safe_request_context
 
 register = template.Library()
 
@@ -43,7 +44,7 @@ def custom_links(context, obj):
     link_context = {
         'object': obj,
         'debug': context.get('debug', False),  # django.template.context_processors.debug
-        'request': context['request'],  # django.template.context_processors.request
+        'request': get_safe_request_context(context['request']),  # Sanitized subset of the request
         'user': context['user'],  # django.contrib.auth.context_processors.auth
         'perms': context['perms'],  # django.contrib.auth.context_processors.auth
     }

+ 55 - 0
netbox/extras/tests/test_templatetags.py

@@ -73,3 +73,58 @@ class CustomLinkTemplateTagTest(TestCase):
         # Re-fetch to clear any cached permissions
         user = User.objects.get(pk=user.pk)
         self.assertIn('Link 1', self.render(user))
+
+
+class CustomLinkRequestSanitizationTest(TestCase):
+    """
+    The custom_links template tag must not expose sensitive request attributes (cookies, headers,
+    session state) to custom link templates (see #22607 / CVE-2026-56715).
+    """
+    SECRET = 'super-secret-session-value'
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site 1', slug='site-1')
+        cls.user = User.objects.create_superuser(username='admin')
+
+    def render_link(self, link_text):
+        custom_link = CustomLink.objects.create(
+            name=f'Custom Link for {link_text!r}',
+            enabled=True,
+            weight=100,
+            new_window=False,
+            link_text=link_text,
+            link_url='http://example.com/'
+        )
+        custom_link.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+        request = RequestFactory().get('/dcim/sites/1/?foo=bar')
+        request.user = self.user
+        request.COOKIES['sessionid'] = self.SECRET
+        context = {
+            'request': request,
+            'user': self.user,
+            'perms': PermWrapper(self.user),
+        }
+        return custom_links(context, self.site)
+
+    def test_cookies_not_exposed(self):
+        # A link interpolating request.COOKIES must not leak the session cookie value.
+        output = self.render_link("{{ request.COOKIES.get('sessionid') }}")
+        self.assertNotIn(self.SECRET, output)
+
+    def test_meta_not_exposed(self):
+        # request.META (which carries cookies, auth headers, etc.) must not be accessible.
+        output = self.render_link('{{ request.META }}')
+        self.assertNotIn(self.SECRET, output)
+
+    def test_headers_not_exposed(self):
+        # request.headers must not be accessible.
+        output = self.render_link('{{ request.headers }}')
+        self.assertNotIn(self.SECRET, output)
+
+    def test_safe_attributes_still_available(self):
+        # Benign attributes remain accessible for backward compatibility.
+        self.assertIn('/dcim/sites/1/', self.render_link('{{ request.path }}'))
+        self.assertIn('bar', self.render_link("{{ request.GET.get('foo') }}"))
+        self.assertIn('admin', self.render_link('{{ request.user }}'))

+ 3 - 9
netbox/extras/webhooks.py

@@ -8,6 +8,7 @@ from jinja2.exceptions import TemplateError
 
 from netbox.registry import registry
 from utilities.proxy import resolve_proxies
+from utilities.request import get_safe_request_context
 
 from .constants import WEBHOOK_EVENT_TYPES
 
@@ -58,16 +59,9 @@ def send_webhook(event_rule, object_type, event_type, data, timestamp, username,
         'data': data,
     }
     if request:
-        context['request'] = {
-            'id': str(request.id) if request.id else None,
-            'method': request.method,
-            'path': request.path,
-            'user': str(request.user),
-        }
+        context['request'] = get_safe_request_context(request)
     if snapshots:
-        context.update({
-            'snapshots': snapshots
-        })
+        context['snapshots'] = snapshots
 
     # Add any additional context from plugins
     callback_data = {}

+ 3 - 2
netbox/netbox/tables/columns.py

@@ -20,6 +20,7 @@ from django_tables2.utils import Accessor
 from extras.choices import CustomFieldTypeChoices
 from utilities.object_types import object_type_identifier, object_type_name
 from utilities.permissions import get_permission_for_model
+from utilities.request import get_safe_request_context
 from utilities.templatetags.builtins.filters import render_markdown
 from utilities.views import get_action_url
 
@@ -582,9 +583,9 @@ class CustomLinkColumn(tables.Column):
             'debug': settings.DEBUG,
         }
         if request := getattr(table, 'context', {}).get('request'):
-            # If the request is available, include it as context
+            # If the request is available, include a sanitized subset of it as context
             context.update({
-                'request': request,
+                'request': get_safe_request_context(request),
                 **auth(request),
             })
 

+ 20 - 0
netbox/utilities/request.py

@@ -17,6 +17,7 @@ __all__ = (
     'apply_request_processors',
     'copy_safe_request',
     'get_client_ip',
+    'get_safe_request_context',
     'safe_for_redirect',
 )
 
@@ -63,6 +64,7 @@ def copy_safe_request(request, include_files=True):
         'user': request.user,
         'method': request.method,
         'path': request.path,
+        'path_info': request.path_info,
         'id': getattr(request, 'id', None),  # UUID assigned by middleware
     }
     if include_files:
@@ -73,6 +75,24 @@ def copy_safe_request(request, include_files=True):
     return NetBoxFakeRequest(data)
 
 
+def get_safe_request_context(request):
+    """
+    Return a sanitized subset of an HttpRequest suitable for exposure to user-authored templates
+    (e.g. custom links). Excludes sensitive data such as cookies, headers, and session state. Returns a
+    plain dict; Jinja2 resolves attribute access (e.g. request.path) against it via getitem fallback.
+    """
+    if request is None:
+        return None
+    return {
+        'id': str(request.id) if hasattr(request, 'id') else None,  # UUID assigned by middleware
+        'path': request.path,
+        'path_info': request.path_info,
+        'method': request.method,
+        'GET': request.GET,
+        'user': str(request.user),  # Username only; not the User instance
+    }
+
+
 def get_client_ip(request, additional_headers=()):
     """
     Return the client (source) IP address of the given request. Accepts an optional list of headers to inspect in

+ 65 - 1
netbox/utilities/tests/test_request.py

@@ -1,8 +1,11 @@
+from django.contrib.auth import get_user_model
 from django.contrib.auth.models import AnonymousUser
 from django.test import RequestFactory, TestCase, override_settings
 from netaddr import IPAddress
 
-from utilities.request import copy_safe_request, get_client_ip
+from utilities.request import copy_safe_request, get_client_ip, get_safe_request_context
+
+User = get_user_model()
 
 
 class CopySafeRequestTestCase(TestCase):
@@ -38,6 +41,67 @@ class CopySafeRequestTestCase(TestCase):
         fake = copy_safe_request(request)
         self.assertNotIn('HTTP_X_CUSTOM_INT', fake.META)
 
+    def test_request_attributes_copied(self):
+        """Core request attributes (path, path_info, method) are copied."""
+        request = self.factory.get('/dcim/sites/1/?foo=bar')
+        request.user = AnonymousUser()
+        fake = copy_safe_request(request)
+        self.assertEqual(fake.path, '/dcim/sites/1/')
+        self.assertEqual(fake.path_info, '/dcim/sites/1/')
+        self.assertEqual(fake.method, 'GET')
+        self.assertEqual(fake.GET.get('foo'), 'bar')
+
+
+class GetSafeRequestContextTestCase(TestCase):
+    def setUp(self):
+        self.factory = RequestFactory()
+
+    def test_only_safe_keys_returned(self):
+        request = self.factory.get('/dcim/sites/1/?foo=bar')
+        request.user = AnonymousUser()
+        request.COOKIES['sessionid'] = 'secret'
+        request.id = 'abc-123'
+
+        context = get_safe_request_context(request)
+
+        self.assertEqual(set(context.keys()), {'id', 'path', 'path_info', 'method', 'GET', 'user'})
+        self.assertEqual(context['id'], 'abc-123')
+        self.assertEqual(context['path'], '/dcim/sites/1/')
+        self.assertEqual(context['method'], 'GET')
+        self.assertEqual(context['GET'].get('foo'), 'bar')
+
+    def test_user_is_username_string(self):
+        """The exposed user must be the username string, not the User instance."""
+        user = User.objects.create_user(username='testuser')
+        request = self.factory.get('/')
+        request.user = user
+
+        context = get_safe_request_context(request)
+
+        self.assertEqual(context['user'], 'testuser')
+        self.assertNotIsInstance(context['user'], User)
+
+    def test_sensitive_attributes_excluded(self):
+        request = self.factory.get('/')
+        request.user = AnonymousUser()
+
+        context = get_safe_request_context(request)
+
+        self.assertNotIn('COOKIES', context)
+        self.assertNotIn('META', context)
+        self.assertNotIn('session', context)
+
+    def test_missing_id_defaults_to_none(self):
+        request = self.factory.get('/')
+        request.user = AnonymousUser()
+
+        context = get_safe_request_context(request)
+
+        self.assertIsNone(context['id'])
+
+    def test_none_request_returns_none(self):
+        self.assertIsNone(get_safe_request_context(None))
+
 
 class GetClientIPTestCase(TestCase):
     def setUp(self):