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(
+ ''
+ '',
+ 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(
+ '
',
+ 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(
+ '',
+ 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(
+ '',
+ 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(
+ '',
+ 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(
+ '',
+ 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(
+ '',
+ 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)