From 560d595ac37c6e66491ba191f8cfae1a3d2b74e8 Mon Sep 17 00:00:00 2001 From: Mohamed Hossam Date: Tue, 18 Aug 2026 18:08:46 +0300 Subject: [PATCH] Closes #22716: Restrict image sources to HTTP(S) and relative URLs --- docs/configuration/security.md | 3 + netbox/utilities/constants.py | 3 + netbox/utilities/html.py | 23 ++++++- netbox/utilities/tests/test_html.py | 101 ++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 netbox/utilities/tests/test_html.py diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 38dcf5d51..3d6ed3cef 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -8,6 +8,9 @@ Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable). +!!! note + Image sources (``) are limited to HTTP(S) and relative URLs, subject to `ALLOWED_URL_SCHEMES`. + --- ## AUTH_PASSWORD_VALIDATORS diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index b82c5b0af..108ef225d 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -110,6 +110,9 @@ HTML_ALLOWED_ATTRIBUTES = { "th": {"align"}, } +# Allowed URL schemes for image sources (img[src]); applied in addition to ALLOWED_URL_SCHEMES +IMAGE_URL_SCHEMES = {'http', 'https'} + HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS = ['socks4', 'socks4a', 'socks4h', 'socks5', 'socks5a', 'socks5h'] HTTP_PROXY_SOCK_RDNS_SCHEMAS = ['socks4h', 'socks4a', 'socks5h', 'socks5a'] HTTP_PROXY_SUPPORTED_SCHEMAS = ['http', 'https', 'socks4', 'socks4a', 'socks4h', 'socks5', 'socks5a', 'socks5h'] diff --git a/netbox/utilities/html.py b/netbox/utilities/html.py index 5b93fa1cf..913680e8e 100644 --- a/netbox/utilities/html.py +++ b/netbox/utilities/html.py @@ -3,7 +3,7 @@ import re import nh3 from django.utils.html import escape -from .constants import HTML_ALLOWED_ATTRIBUTES, HTML_ALLOWED_TAGS +from .constants import HTML_ALLOWED_ATTRIBUTES, HTML_ALLOWED_TAGS, IMAGE_URL_SCHEMES __all__ = ( 'clean_html', @@ -11,17 +11,36 @@ __all__ = ( 'highlight', ) +SCHEME_RE = re.compile(r'^([a-zA-Z][a-zA-Z0-9+.-]*):') + +# Per the URL spec, browsers ignore leading/trailing C0 control characters & space, and strip any tab or +# newline characters appearing within a URL. We must normalize accordingly before checking the scheme. +URL_STRIP_CHARS = ''.join(chr(c) for c in range(0x21)) +URL_REMOVE_CHARS = str.maketrans('', '', '\t\r\n') + + +def _attribute_filter(tag, attr, value): + """Returns str to keep/modify attribute, None to remove it.""" + if tag == 'img' and attr == 'src': + match = SCHEME_RE.match(value.strip(URL_STRIP_CHARS).translate(URL_REMOVE_CHARS)) + if match and match.group(1).lower() not in IMAGE_URL_SCHEMES: + return None + return value + def clean_html(html, schemes): """ Sanitizes HTML based on a whitelist of allowed tags and attributes. Also takes a list of allowed URI schemes. """ + url_schemes = set(schemes) + attribute_filter = None if url_schemes <= IMAGE_URL_SCHEMES else _attribute_filter return nh3.clean( html, tags=HTML_ALLOWED_TAGS, attributes=HTML_ALLOWED_ATTRIBUTES, - url_schemes=set(schemes) + url_schemes=url_schemes, + attribute_filter=attribute_filter, ) diff --git a/netbox/utilities/tests/test_html.py b/netbox/utilities/tests/test_html.py new file mode 100644 index 000000000..6d70cfb4c --- /dev/null +++ b/netbox/utilities/tests/test_html.py @@ -0,0 +1,101 @@ +from django.test import SimpleTestCase, tag + +from utilities.html import clean_html + +TEST_SCHEMES = ['file', 'ftp', 'ssh', 'http', 'https'] + + +class CleanHTMLURLPolicyTestCase(SimpleTestCase): + + @tag('regression') + def test_img_src_disallowed_schemes(self): + """ + file:/ftp:/ssh: image sources are stripped while non-src attributes survive. + (Core regression: these schemes are in ALLOWED_URL_SCHEMES but forbidden for images.) + """ + html = ( + 'a' + 'b' + 'c' + ) + result = clean_html(html, TEST_SCHEMES) + self.assertNotIn('file:///', result) + self.assertNotIn('ftp://', result) + self.assertNotIn('ssh://', result) + self.assertIn('alt="a"', result) + self.assertIn('alt="b"', result) + self.assertIn('alt="c"', result) + + @tag('regression') + def test_img_src_leading_whitespace(self): + """Disallowed schemes hidden behind leading whitespace are still stripped.""" + html = '' + result = clean_html(html, TEST_SCHEMES) + self.assertNotIn('ssh://', result) + + @tag('regression') + def test_img_src_embedded_control_characters(self): + """Disallowed schemes obscured by tab/newline/control characters are still stripped.""" + html = ( + '' + '' + '' + ) + result = clean_html(html, TEST_SCHEMES) + self.assertNotIn('h://host', result) + self.assertNotIn('le:///etc', result) + self.assertNotIn('ftp://host', result) + + @tag('regression') + def test_img_src_http_https_and_relative(self): + """http/https/relative image sources are retained.""" + html = ( + '' + '' + '' + '' + '' + ) + result = clean_html(html, TEST_SCHEMES) + self.assertIn('example.com/i1.png', result) + self.assertIn('example.com/i2.png', result) + self.assertIn('src="/rel.png"', result) + self.assertIn('src="rel.png"', result) + self.assertIn('src="//cdn.example.com/i3.png"', result) + + @tag('regression') + def test_link_href_preserves_all_schemes(self): + """a[href] behavior is unchanged — all configured schemes remain allowed.""" + html = ( + 's' + 'f' + 'f' + 'h' + ) + result = clean_html(html, TEST_SCHEMES) + self.assertIn('href="ssh://host"', result) + self.assertIn('href="file:///etc"', result) + self.assertIn('href="ftp://host"', result) + self.assertIn('href="https://example.com"', result) + + @tag('regression') + def test_mixed_content(self): + """Different policies for img vs a in the same input.""" + html = 'L' + result = clean_html(html, TEST_SCHEMES) + self.assertIn('href="ssh://host"', result) + self.assertNotIn('ssh://host/i.png', result) + + @tag('regression') + def test_javascript_url_blocked(self): + """nh3's url_schemes filtering still applies with the attribute filter in place.""" + html = 'x' + result = clean_html(html, ['https', 'mailto']) + self.assertNotIn('javascript:', result) + + @tag('regression') + def test_img_src_respects_allowed_url_schemes(self): + """Image sources must respect ALLOWED_URL_SCHEMES, not just IMAGE_URL_SCHEMES.""" + html = '' + result = clean_html(html, ['https', 'mailto']) + self.assertNotIn('http://example.com', result)