This commit is contained in:
parent
a6da836df8
commit
df83277156
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}'))
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue