diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index cd1d29e7d..88b31aeb5 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,6 +1,7 @@ from unittest.mock import patch from django import forms +from django.template.loader import render_to_string from django.test import TestCase from dcim.choices import ( @@ -229,6 +230,46 @@ class ModuleTypeFormTestCase(TestCase): self.assertEqual(module_type.attribute_data, {'media': ['copper', 'qsfp28']}) +class ModuleTypeProfileDescriptionRenderingTestCase(TestCase): + """ + A profile schema property's description is rendered as the attribute field's help text via the + `safe` filter, so markup outside HTML_ALLOWED_TAGS must not reach the DOM as a live element. + Verified end to end because the sanitization and the `safe` filter that makes it necessary sit + in different layers. + """ + + @classmethod + def setUpTestData(cls): + cls.manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + cls.profile = ModuleTypeProfile.objects.create( + name='Disk', + schema={ + 'properties': { + 'capacity': { + 'type': 'integer', + 'title': 'Capacity (GB)', + 'description': 'Gross disk size ', + }, + }, + }, + ) + + def test_help_text_is_rendered_without_disallowed_markup(self): + form = ModuleTypeForm(data={ + 'manufacturer': self.manufacturer.pk, + 'model': 'Module Type 1', + 'profile': self.profile.pk, + 'attr_capacity': 500, + }) + rendered = render_to_string('form_helpers/render_field.html', {'field': form['attr_capacity']}) + + self.assertInHTML( + '' + '

Gross disk size

', + rendered, + ) + + class ModuleBayTemplateImportFormTestCase(TestCase): def test_module_bay_types_prefers_manufacturer_specific_match_over_global(self): diff --git a/netbox/utilities/jsonschema.py b/netbox/utilities/jsonschema.py index a25e24f21..418eb300e 100644 --- a/netbox/utilities/jsonschema.py +++ b/netbox/utilities/jsonschema.py @@ -11,6 +11,7 @@ from jsonschema.exceptions import SchemaError from jsonschema.validators import validator_for from utilities.string import title +from utilities.templatetags.builtins.filters import render_markdown from utilities.validators import MultipleOfValidator __all__ = ( @@ -88,7 +89,7 @@ class JSONSchemaProperty: """ field_kwargs = { 'label': self.title or title(name), - 'help_text': self.description, + 'help_text': render_markdown(self.description), 'required': required, 'initial': self.default, } diff --git a/netbox/utilities/tests/test_jsonschema.py b/netbox/utilities/tests/test_jsonschema.py index 2526bc84d..50210fc48 100644 --- a/netbox/utilities/tests/test_jsonschema.py +++ b/netbox/utilities/tests/test_jsonschema.py @@ -44,3 +44,140 @@ class JSONSchemaPropertyTestCase(TestCase): self.assertIsInstance(field, SimpleArrayField) self.assertIsInstance(field.base_field, forms.CharField) self.assertEqual(field.clean('ge-0/0/0,ge-0/0/1'), ['ge-0/0/0', 'ge-0/0/1']) + + +class JSONSchemaPropertyDescriptionSanitizationTestCase(TestCase): + """ + A property's description becomes the form field's help_text, which is rendered through the + `safe` filter in form_helpers/render_field.html. It is passed through render_markdown(), which + applies the HTML_ALLOWED_TAGS allowlist, matching the custom field path in + extras.models.customfields.CustomField.to_form_field(). + + Each test compares the entire help text, so a payload surviving anywhere in it fails the + assertion. Asserting only on the absence of a substring would not, because stripping an + element leaves its text behind as character data. + """ + + def test_disallowed_element_is_stripped(self): + prop = JSONSchemaProperty( + type='integer', + title='Capacity (GB)', + description='Gross disk size ', + ) + + field = prop.to_form_field('capacity') + + self.assertHTMLEqual( + '

Gross disk size

', + field.help_text, + ) + + def test_script_element_is_stripped(self): + prop = JSONSchemaProperty( + type='string', + description='Vendor code ', + ) + + field = prop.to_form_field('vendor_code') + + self.assertHTMLEqual( + '

Vendor code

', + field.help_text, + ) + + def test_event_handler_attribute_is_stripped(self): + """An allowed tag carrying a disallowed attribute keeps the tag but loses the attribute.""" + prop = JSONSchemaProperty( + type='string', + description='Vendor code', + ) + + field = prop.to_form_field('vendor_code') + + self.assertHTMLEqual( + '

Vendor code

', + field.help_text, + ) + + def test_javascript_uri_is_stripped(self): + prop = JSONSchemaProperty( + type='string', + description='Vendor code', + ) + + field = prop.to_form_field('vendor_code') + + self.assertHTMLEqual( + '
' + '

Vendor code

', + field.help_text, + ) + + def test_disallowed_element_is_stripped_from_mixed_markup(self): + """A disallowed element is dropped while its allowed siblings are kept.""" + prop = JSONSchemaProperty( + type='string', + description='Vendor code ', + ) + + field = prop.to_form_field('vendor_code') + + self.assertHTMLEqual( + '

Vendor code

', + field.help_text, + ) + + def test_allowed_markup_is_preserved(self): + """ + render_markdown() applies the HTML_ALLOWED_TAGS allowlist, so markup inside it survives. + This is the behavior that keeps schema descriptions consistent with custom field + descriptions. + """ + prop = JSONSchemaProperty( + type='integer', + description='Gross disk size in GB', + ) + + field = prop.to_form_field('capacity') + + self.assertHTMLEqual( + '

Gross disk size in GB

', + field.help_text, + ) + + def test_markdown_is_rendered(self): + """Descriptions are interpreted as Markdown, matching the custom field path.""" + prop = JSONSchemaProperty( + type='integer', + description='Gross disk size in **GB**', + ) + + field = prop.to_form_field('capacity') + + self.assertHTMLEqual( + '
' + '

Gross disk size in GB

', + field.help_text, + ) + + def test_description_text_is_retained(self): + """Sanitization must not discard the author's actual help text.""" + prop = JSONSchemaProperty( + type='string', + description='Gross disk size in gigabytes', + ) + + field = prop.to_form_field('capacity') + + self.assertHTMLEqual( + '

Gross disk size in gigabytes

', + field.help_text, + ) + + def test_absent_description_yields_no_help_text(self): + """A property without a description must not gain help text from the sanitizer.""" + prop = JSONSchemaProperty(type='string', title='Vendor Code') + + field = prop.to_form_field('vendor_code') + + self.assertFalse(field.help_text)