Merge pull request #23174 from netbox-community/23167-module-type-profile-desc-not-sanitized

Fixes #23167: Sanitize JSON schema property descriptions used as form help text
This commit is contained in:
bctiemann 2026-09-15 10:30:35 -04:00 committed by GitHub
commit 1793874dd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 180 additions and 1 deletions

View File

@ -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 <iframe src="https://example.com"></iframe>',
},
},
},
)
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(
'<span class="form-text" id="id_attr_capacity_helptext">'
'<div class="rendered-markdown"><p>Gross disk size</p></div></span>',
rendered,
)
class ModuleBayTemplateImportFormTestCase(TestCase):
def test_module_bay_types_prefers_manufacturer_specific_match_over_global(self):

View File

@ -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,
}

View File

@ -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 <iframe src="https://example.com"></iframe>',
)
field = prop.to_form_field('capacity')
self.assertHTMLEqual(
'<div class="rendered-markdown"><p>Gross disk size</p></div>',
field.help_text,
)
def test_script_element_is_stripped(self):
prop = JSONSchemaProperty(
type='string',
description='Vendor code <script>alert(1)</script>',
)
field = prop.to_form_field('vendor_code')
self.assertHTMLEqual(
'<div class="rendered-markdown"><p>Vendor code</p></div>',
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='<b onmouseover="alert(1)">Vendor code</b>',
)
field = prop.to_form_field('vendor_code')
self.assertHTMLEqual(
'<div class="rendered-markdown"><p><b>Vendor code</b></p></div>',
field.help_text,
)
def test_javascript_uri_is_stripped(self):
prop = JSONSchemaProperty(
type='string',
description='<a href="javascript:alert(1)">Vendor code</a>',
)
field = prop.to_form_field('vendor_code')
self.assertHTMLEqual(
'<div class="rendered-markdown">'
'<p><a rel="noopener noreferrer">Vendor code</a></p></div>',
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='<b>Vendor</b> code <iframe src="https://example.com"></iframe>',
)
field = prop.to_form_field('vendor_code')
self.assertHTMLEqual(
'<div class="rendered-markdown"><p><b>Vendor</b> code</p></div>',
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 <code>GB</code>',
)
field = prop.to_form_field('capacity')
self.assertHTMLEqual(
'<div class="rendered-markdown"><p>Gross disk size in <code>GB</code></p></div>',
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(
'<div class="rendered-markdown">'
'<p>Gross disk size in <strong>GB</strong></p></div>',
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(
'<div class="rendered-markdown"><p>Gross disk size in gigabytes</p></div>',
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)