Преглед изворни кода

Merge pull request #22676 from netbox-community/22675-rss

#22675 Validate RSS feed entry link schemes to prevent javascript: XSS
bctiemann пре 6 дана
родитељ
комит
425b70275e
2 измењених фајлова са 110 додато и 4 уклоњено
  1. 31 3
      netbox/extras/dashboard/widgets.py
  2. 79 1
      netbox/extras/tests/test_dashboard.py

+ 31 - 3
netbox/extras/dashboard/widgets.py

@@ -2,7 +2,7 @@ import logging
 import uuid
 from functools import cached_property
 from hashlib import sha256
-from urllib.parse import urlencode
+from urllib.parse import urlencode, urlparse
 
 import feedparser
 import requests
@@ -16,6 +16,8 @@ from django.utils.translation import gettext as _
 
 from core.models import ObjectType
 from extras.choices import BookmarkOrderingChoices
+from netbox.config import get_config
+from utilities.html import clean_html
 from utilities.object_types import object_type_identifier, object_type_name
 from utilities.permissions import get_permission_for_model
 from utilities.proxy import resolve_proxies
@@ -356,7 +358,9 @@ class RSSFeedWidget(DashboardWidget):
     def cache_key(self):
         url = self.config['feed_url']
         url_checksum = sha256(url.encode('utf-8')).hexdigest()
-        return f'dashboard_rss_{url_checksum}'
+        # The version segment invalidates entries cached by a pre-sanitization release: such
+        # entries live under the old key and are never read, so they can't be served unsanitized.
+        return f'dashboard_rss_2_{url_checksum}'
 
     def get_feed(self):
         if self.config.get('requires_internet') and settings.ISOLATED_DEPLOYMENT:
@@ -364,7 +368,8 @@ class RSSFeedWidget(DashboardWidget):
                 'isolated_deployment': True,
             }
 
-        # Fetch RSS content from cache if available
+        # Fetch RSS content from cache if available. Cached content is always sanitized before
+        # it is written (see below), so no sanitization is needed on read.
         if feed_content := cache.get(self.cache_key):
             return {
                 'feed': feedparser.FeedParserDict(feed_content),
@@ -390,6 +395,8 @@ class RSSFeedWidget(DashboardWidget):
             # Cap number of entries
             max_entries = self.config.get('max_entries')
             feed['entries'] = feed['entries'][:max_entries]
+            # Sanitize feed-controlled content before caching/rendering
+            self.sanitize_entries(feed['entries'])
             # Cache the feed content
             cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
 
@@ -397,6 +404,27 @@ class RSSFeedWidget(DashboardWidget):
             'feed': feed,
         }
 
+    @staticmethod
+    def sanitize_entries(entries):
+        """
+        Sanitize feed-controlled entry content in place. The feed URL is untrusted external
+        content, so we must guard against dangerous URL schemes (e.g. javascript:) in entry
+        links and sanitize entry summaries as defense-in-depth.
+        """
+        allowed_schemes = get_config().ALLOWED_URL_SCHEMES
+        for entry in entries:
+            # Blank any link whose scheme isn't permitted (blocks javascript:, data:, etc.).
+            # This is the load-bearing control: the template renders entry.link into an href.
+            if link := entry.get('link'):
+                result = urlparse(link)
+                if result.scheme and result.scheme.lower() not in allowed_schemes:
+                    entry['link'] = ''
+            # Sanitize the summary HTML as defense-in-depth. The template renders entry.summary
+            # with auto-escaping (not |safe), so this is not currently load-bearing; it guards
+            # against a future change that renders the summary as markup.
+            if summary := entry.get('summary'):
+                entry['summary'] = clean_html(summary, allowed_schemes)
+
 
 @register_widget
 class BookmarksWidget(DashboardWidget):

+ 79 - 1
netbox/extras/tests/test_dashboard.py

@@ -1,6 +1,9 @@
+from unittest.mock import MagicMock, patch
+
+from django.core.cache import cache
 from django.test import RequestFactory, TestCase, tag
 
-from extras.dashboard.widgets import ObjectListWidget
+from extras.dashboard.widgets import ObjectListWidget, RSSFeedWidget
 from extras.templatetags.dashboard import render_widget
 
 
@@ -49,6 +52,81 @@ class ObjectListWidgetTestCase(TestCase):
         self.assertTrue('Unable to load content. Could not resolve list URL for:' in rendered)
 
 
+class RSSFeedWidgetSanitizationTestCase(TestCase):
+    """
+    Feed entry content is externally controlled and untrusted. Links must be validated against
+    ALLOWED_URL_SCHEMES so dangerous schemes (e.g. javascript:) cannot become clickable XSS sinks.
+    """
+
+    @tag('regression')
+    def test_sanitize_entries_blanks_disallowed_schemes(self):
+        entries = [
+            {'link': 'javascript:alert(document.cookie)', 'title': 't1'},
+            {'link': 'JavaScript:alert(1)', 'title': 't2'},  # case-insensitive
+            {'link': 'data:text/html,<script>alert(1)</script>', 'title': 't3'},
+            {'link': 'vbscript:msgbox(1)', 'title': 't4'},
+        ]
+        RSSFeedWidget.sanitize_entries(entries)
+        for entry in entries:
+            self.assertEqual(entry['link'], '', msg=f"Failed to blank {entry['title']}")
+
+    @tag('regression')
+    def test_sanitize_entries_preserves_allowed_links(self):
+        entries = [
+            {'link': 'https://example.com/post', 'title': 't1'},
+            {'link': 'http://example.com/post', 'title': 't2'},
+            {'link': 'mailto:user@example.com', 'title': 't3'},
+            {'link': '/relative/path', 'title': 't4'},  # schemeless relative link
+        ]
+        expected = [e['link'] for e in entries]
+        RSSFeedWidget.sanitize_entries(entries)
+        self.assertEqual([e['link'] for e in entries], expected)
+
+    @tag('regression')
+    def test_sanitize_entries_cleans_summary_html(self):
+        entries = [
+            {'link': 'https://example.com', 'title': 't1', 'summary': '<b>ok</b><script>alert(1)</script>'},
+        ]
+        RSSFeedWidget.sanitize_entries(entries)
+        self.assertNotIn('<script>', entries[0]['summary'])
+        self.assertIn('<b>ok</b>', entries[0]['summary'])
+
+    @tag('regression')
+    def test_get_feed_sanitizes_before_caching(self):
+        """
+        Fetched feed content must be sanitized before it is rendered or written to the cache,
+        so a poisoned link is never stored or served.
+        """
+        widget = RSSFeedWidget(config={
+            'feed_url': 'https://example.com/feed.xml',
+            'requires_internet': False,
+            'max_entries': 10,
+            'cache_timeout': 3600,
+        })
+        rss = (
+            b'<?xml version="1.0"?>'
+            b'<rss version="2.0"><channel><title>t</title>'
+            b'<link>http://example.com</link><description>d</description>'
+            b'<item><title>evil</title><link>javascript:alert(1)</link>'
+            b'<description>d</description></item>'
+            b'</channel></rss>'
+        )
+        mock_response = MagicMock()
+        mock_response.content = rss
+
+        with (
+            patch('extras.dashboard.widgets.requests.get', return_value=mock_response),
+            patch('extras.dashboard.widgets.resolve_proxies', return_value={}),
+        ):
+            result = widget.get_feed()
+
+        # The rendered feed is sanitized...
+        self.assertEqual(result['feed']['entries'][0]['link'], '')
+        # ...and the cached copy is sanitized too (never stored poisoned).
+        cached = cache.get(widget.cache_key)
+        self.assertEqual(cached['entries'][0]['link'], '')
+
+
 class RenderWidgetTemplateTagTestCase(TestCase):
 
     def _make_context(self):