test_dashboard.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. from unittest.mock import MagicMock, patch
  2. from django.core.cache import cache
  3. from django.test import RequestFactory, TestCase, tag
  4. from extras.dashboard.widgets import ObjectListWidget, RSSFeedWidget
  5. from extras.templatetags.dashboard import render_widget
  6. class ObjectListWidgetTestCase(TestCase):
  7. def test_widget_config_form_validates_model(self):
  8. model_info = 'extras.notification'
  9. form = ObjectListWidget.ConfigForm({'model': model_info})
  10. self.assertFalse(form.is_valid())
  11. @tag('regression')
  12. def test_widget_fails_gracefully(self):
  13. """
  14. Example:
  15. '2829fd9b-5dee-4c9a-81f2-5bd84c350a27': {
  16. 'class': 'extras.ObjectListWidget',
  17. 'color': 'indigo',
  18. 'title': 'Object List',
  19. 'config': {
  20. 'model': 'extras.notification',
  21. 'page_size': None,
  22. 'url_params': None
  23. }
  24. }
  25. """
  26. config = {
  27. # 'class': 'extras.ObjectListWidget', # normally popped off, left for clarity
  28. 'color': 'yellow',
  29. 'title': 'this should fail',
  30. 'config': {
  31. 'model': 'extras.notification',
  32. 'page_size': None,
  33. 'url_params': None,
  34. },
  35. }
  36. class Request:
  37. class User:
  38. def has_perm(self, *args, **kwargs):
  39. return True
  40. user = User()
  41. mock_request = Request()
  42. widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
  43. rendered = widget.render(mock_request)
  44. self.assertTrue('Unable to load content. Could not resolve list URL for:' in rendered)
  45. class RSSFeedWidgetSanitizationTestCase(TestCase):
  46. """
  47. Feed entry content is externally controlled and untrusted. Links must be validated against
  48. ALLOWED_URL_SCHEMES so dangerous schemes (e.g. javascript:) cannot become clickable XSS sinks.
  49. """
  50. @tag('regression')
  51. def test_sanitize_entries_blanks_disallowed_schemes(self):
  52. entries = [
  53. {'link': 'javascript:alert(document.cookie)', 'title': 't1'},
  54. {'link': 'JavaScript:alert(1)', 'title': 't2'}, # case-insensitive
  55. {'link': 'data:text/html,<script>alert(1)</script>', 'title': 't3'},
  56. {'link': 'vbscript:msgbox(1)', 'title': 't4'},
  57. ]
  58. RSSFeedWidget.sanitize_entries(entries)
  59. for entry in entries:
  60. self.assertEqual(entry['link'], '', msg=f"Failed to blank {entry['title']}")
  61. @tag('regression')
  62. def test_sanitize_entries_preserves_allowed_links(self):
  63. entries = [
  64. {'link': 'https://example.com/post', 'title': 't1'},
  65. {'link': 'http://example.com/post', 'title': 't2'},
  66. {'link': 'mailto:user@example.com', 'title': 't3'},
  67. {'link': '/relative/path', 'title': 't4'}, # schemeless relative link
  68. ]
  69. expected = [e['link'] for e in entries]
  70. RSSFeedWidget.sanitize_entries(entries)
  71. self.assertEqual([e['link'] for e in entries], expected)
  72. @tag('regression')
  73. def test_sanitize_entries_cleans_summary_html(self):
  74. entries = [
  75. {'link': 'https://example.com', 'title': 't1', 'summary': '<b>ok</b><script>alert(1)</script>'},
  76. ]
  77. RSSFeedWidget.sanitize_entries(entries)
  78. self.assertNotIn('<script>', entries[0]['summary'])
  79. self.assertIn('<b>ok</b>', entries[0]['summary'])
  80. @tag('regression')
  81. def test_get_feed_sanitizes_before_caching(self):
  82. """
  83. Fetched feed content must be sanitized before it is rendered or written to the cache,
  84. so a poisoned link is never stored or served.
  85. """
  86. widget = RSSFeedWidget(config={
  87. 'feed_url': 'https://example.com/feed.xml',
  88. 'requires_internet': False,
  89. 'max_entries': 10,
  90. 'cache_timeout': 3600,
  91. })
  92. rss = (
  93. b'<?xml version="1.0"?>'
  94. b'<rss version="2.0"><channel><title>t</title>'
  95. b'<link>http://example.com</link><description>d</description>'
  96. b'<item><title>evil</title><link>javascript:alert(1)</link>'
  97. b'<description>d</description></item>'
  98. b'</channel></rss>'
  99. )
  100. mock_response = MagicMock()
  101. mock_response.content = rss
  102. with (
  103. patch('extras.dashboard.widgets.requests.get', return_value=mock_response),
  104. patch('extras.dashboard.widgets.resolve_proxies', return_value={}),
  105. ):
  106. result = widget.get_feed()
  107. # The rendered feed is sanitized...
  108. self.assertEqual(result['feed']['entries'][0]['link'], '')
  109. # ...and the cached copy is sanitized too (never stored poisoned).
  110. cached = cache.get(widget.cache_key)
  111. self.assertEqual(cached['entries'][0]['link'], '')
  112. class RenderWidgetTemplateTagTestCase(TestCase):
  113. def _make_context(self):
  114. request = RequestFactory().get('/')
  115. return {'request': request}
  116. def test_render_widget_escapes_exception_html(self):
  117. """Exception text with HTML special chars must be escaped, not rendered as markup."""
  118. class BrokenWidget:
  119. def render(self, request):
  120. raise Exception('<script>alert(1)</script>')
  121. output = render_widget(self._make_context(), BrokenWidget())
  122. self.assertIn('&lt;script&gt;', output)
  123. self.assertNotIn('<script>', output)
  124. def test_render_widget_escapes_exception_angle_brackets(self):
  125. """Angle brackets in exception messages are escaped."""
  126. class BrokenWidget:
  127. def render(self, request):
  128. raise ValueError('invalid value: <bad>')
  129. output = render_widget(self._make_context(), BrokenWidget())
  130. self.assertIn('&lt;bad&gt;', output)
  131. self.assertNotIn('<bad>', output)