|
|
@@ -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):
|