diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index bb3eac01e..03f85a7f0 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/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 @@ -366,8 +368,12 @@ class RSSFeedWidget(DashboardWidget): # Fetch RSS content from cache if available if feed_content := cache.get(self.cache_key): + feed = feedparser.FeedParserDict(feed_content) + # Sanitize on read as well: cached content may have been written by a pre-fix + # release (during an upgrade) and thus not yet sanitized. Idempotent. + self.sanitize_entries(feed.get('entries', [])) return { - 'feed': feedparser.FeedParserDict(feed_content), + 'feed': feed, } # Fetch feed content from remote server @@ -390,6 +396,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 +405,24 @@ 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.) + 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 + if summary := entry.get('summary'): + entry['summary'] = clean_html(summary, allowed_schemes) + @register_widget class BookmarksWidget(DashboardWidget):