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:
commit
1793874dd9
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue