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