From a4dcd826064a4de785d9367fa35447bc99bb3e1e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Aug 2026 13:47:36 -0400 Subject: [PATCH] Fixes #22894: Sanitize error message rendered during exception in CustomLinkColumn (#22895) --- netbox/extras/tests/test_templatetags.py | 37 ++++++++++++++++++++++++ netbox/extras/tests/test_views.py | 25 ++++++++++++++++ netbox/netbox/tables/columns.py | 4 ++- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/netbox/extras/tests/test_templatetags.py b/netbox/extras/tests/test_templatetags.py index 9317acd93..426373dd7 100644 --- a/netbox/extras/tests/test_templatetags.py +++ b/netbox/extras/tests/test_templatetags.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.context_processors import PermWrapper from django.test import RequestFactory, TestCase +from django.utils.html import escape from core.models import ObjectType from dcim.models import Site @@ -84,6 +85,12 @@ class CustomLinkRenderErrorEscapingTest(TestCase): XSS_NAME = '' ESCAPED_NAME = '<img src=x onerror=alert(1)>' + # Subscripting a string with a nonexistent attribute yields an Undefined, and operating on it raises + # UndefinedError. These tests depend on Jinja2 quoting the subscript verbatim in that message (currently + # "'str object' has no attribute ''"); a change to Jinja2's message format would break them. + XSS_PAYLOAD = '" >' + FAILING_TEMPLATE = f"{{{{ ''['{XSS_PAYLOAD}'] + 1 }}}}" + @classmethod def setUpTestData(cls): cls.site = Site.objects.create(name='Site 1', slug='site-1') @@ -134,3 +141,33 @@ class CustomLinkRenderErrorEscapingTest(TestCase): rendered = self.render(self.make_user_with_view_permission('user2')) self.assertNotIn(self.XSS_NAME, rendered) self.assertIn(self.ESCAPED_NAME, rendered) + + def test_render_error_escapes_exception_message(self): + # The exception message reproduces the (attacker-controlled) template code, so it must be escaped + # in the error fallback as well (NB-3311). + custom_link = CustomLink.objects.create( + name='Custom Link 1', + enabled=True, + link_text=self.FAILING_TEMPLATE, + link_url='http://example.com/', + ) + custom_link.object_types.set([ObjectType.objects.get_for_model(Site)]) + + rendered = self.render(self.make_user_with_view_permission('user3')) + self.assertNotIn(self.XSS_PAYLOAD, rendered) + self.assertIn(escape(self.XSS_PAYLOAD), rendered) + + def test_render_error_escapes_grouped_exception_message(self): + # The grouped-link error fallback must likewise escape the exception message (NB-3311). + custom_link = CustomLink.objects.create( + name='Custom Link 1', + enabled=True, + group_name='Group 1', + link_text=self.FAILING_TEMPLATE, + link_url='http://example.com/', + ) + custom_link.object_types.set([ObjectType.objects.get_for_model(Site)]) + + rendered = self.render(self.make_user_with_view_permission('user4')) + self.assertNotIn(self.XSS_PAYLOAD, rendered) + self.assertIn(escape(self.XSS_PAYLOAD), rendered) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 74d95e242..a977b4f2f 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.messages import get_messages from django.test import tag from django.urls import reverse +from django.utils.html import escape from core.choices import JobStatusChoices, ManagedFileRootPathChoices from core.events import * @@ -245,6 +246,30 @@ class CustomLinkRenderingTestCase(TestCase): self.assertEqual(response.status_code, 200) self.assertNotIn(f'FOO {site.name} BAR', str(response.content)) + def test_list_view_custom_link_column_escapes_render_error(self): + # Jinja2 includes the invalid key verbatim in UndefinedError; this test intentionally depends on that format. + payload = '" >' + customlink = CustomLink( + name='Test', + link_text=f"{{{{ ''['{payload}'] + 1 }}}}", + link_url='http://example.com/', + new_window=False + ) + customlink.save() + customlink.object_types.set([ObjectType.objects.get_for_model(Site)]) + + site = Site(name='Test Site', slug='test-site') + site.save() + + response = self.client.get(f"{reverse('dcim:site_list')}?include_columns=cl_Test") + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + # The error element must be present, but the payload must appear only in escaped form + self.assertIn('{rendered["text"]}') except Exception as e: error_text = _('Error') - return mark_safe(f' {error_text}') + return format_html( + ' {}', e, error_text + ) return '' def value(self, record, table, **kwargs):