You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/09/16 23:51:02 UTC

(superset) branch master updated: fix(themes): render brand spinner SVG via data URI (#43504)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 181e9ad3e2b fix(themes): render brand spinner SVG via <img> data URI (#43504)
181e9ad3e2b is described below

commit 181e9ad3e2b0497fa3369a9a7c853633e948934a
Author: n4uu <n4...@pm.me>
AuthorDate: Thu Sep 17 01:50:56 2026 +0200

    fix(themes): render brand spinner SVG via <img> data URI (#43504)
    
    Signed-off-by: n4uu <n4...@users.noreply.github.com>
    Co-authored-by: n4uu <n4...@users.noreply.github.com>
    Co-authored-by: rusackas <ev...@rusackas.com>
    Co-authored-by: Claude Sonnet 5 <no...@anthropic.com>
---
 superset/templates/superset/spa.html              | 13 +++--
 superset/views/base.py                            | 21 +++++++-
 tests/unit_tests/views/test_base_theme_helpers.py | 62 +++++++++++++++++++++++
 3 files changed, 90 insertions(+), 6 deletions(-)

diff --git a/superset/templates/superset/spa.html b/superset/templates/superset/spa.html
index f0ba0eec534..416e19c6ae7 100644
--- a/superset/templates/superset/spa.html
+++ b/superset/templates/superset/spa.html
@@ -144,11 +144,14 @@
         {% set tokens = theme_tokens | default({}) %}
         {% set spinner_style = "width: 70px; height: auto; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);" %}
 
-        {% if spinner_svg %}
-          <!-- Inline SVG: Either custom theme SVG or default spinner -->
-          <div style="{{ spinner_style }}">
-            {{ spinner_svg | safe }}
-          </div>
+        {% if spinner_svg_data_uri %}
+          <!-- data URI: SVG renders as a passive image, no script execution -->
+          <img
+            src="{{ spinner_svg_data_uri }}"
+            alt=""
+            aria-label="Loading"
+            style="{{ spinner_style }}"
+          />
         {% elif tokens.get('brandSpinnerUrl') %}
           <!-- Custom URL from theme -->
           <img
diff --git a/superset/views/base.py b/superset/views/base.py
index 213e211f952..f5f48fea5f0 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import base64
 import copy
 import functools
 import logging
@@ -754,6 +755,20 @@ def _ensure_static_assets_prefix(url_or_path: str) -> str:
     return f"{normalized_prefix}{url_or_path}"
 
 
+def _svg_to_data_uri(svg: str | None) -> str | None:
+    """Encode SVG markup as a base64 data URI, or ``None`` if no SVG is given.
+
+    A malformed theme (hand-edited config or a stale database row) could
+    supply a truthy non-string value here; treat that as absent rather than
+    raising, since raising would 500 every SPA render.
+    """
+    if not isinstance(svg, str) or not svg:
+        return None
+    return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode(
+        "ascii"
+    )
+
+
 def get_spa_template_context(
     entry: str | None = "spa",
     extra_bootstrap_data: dict[str, Any] | None = None,
@@ -823,6 +838,10 @@ def get_spa_template_context(
         # No custom URL either, use default SVG
         spinner_svg = get_default_spinner_svg()
 
+    # Serve the spinner as an <img> data URI (see spa.html). SVG loaded via <img>
+    # can't execute scripts, so rendering doesn't depend on sanitize_svg_content.
+    spinner_svg_data_uri = _svg_to_data_uri(spinner_svg)
+
     # Determine default title using the (potentially updated) brandAppName
     default_title = theme_tokens.get("brandAppName", "Superset")
 
@@ -837,7 +856,7 @@ def get_spa_template_context(
         ),
         "theme_tokens": theme_tokens,
         "dark_theme_bg": dark_theme_bg,
-        "spinner_svg": spinner_svg,
+        "spinner_svg_data_uri": spinner_svg_data_uri,
         "default_title": default_title,
         **get_language_pack_template_context(payload.get("common") or {}),
         **template_kwargs,
diff --git a/tests/unit_tests/views/test_base_theme_helpers.py b/tests/unit_tests/views/test_base_theme_helpers.py
index adc601dee14..1b0e9c5eff0 100644
--- a/tests/unit_tests/views/test_base_theme_helpers.py
+++ b/tests/unit_tests/views/test_base_theme_helpers.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import base64
 from unittest.mock import MagicMock, mock_open, patch
 
 from superset.themes.types import ThemeMode
@@ -23,6 +24,7 @@ from superset.views.base import (
     _load_theme_from_model,
     _merge_theme_dicts,
     _process_theme,
+    _svg_to_data_uri,
     get_theme_bootstrap_data,
 )
 
@@ -1119,6 +1121,66 @@ class TestGetDefaultSpinnerSvg:
         assert "morphPath" in result
 
 
+class TestSvgToDataUri:
+    """Test _svg_to_data_uri (see #43504: <img> data URI spinner rendering)"""
+
+    def test_string_input_encoded_as_data_uri(self):
+        """A valid SVG string is base64-encoded into a data URI"""
+        svg = "<svg>ok</svg>"
+        result = _svg_to_data_uri(svg)
+        assert result == (
+            "data:image/svg+xml;base64,"
+            + base64.b64encode(svg.encode("utf-8")).decode("ascii")
+        )
+
+    def test_none_input_returns_none(self):
+        assert _svg_to_data_uri(None) is None
+
+    def test_empty_string_returns_none(self):
+        assert _svg_to_data_uri("") is None
+
+    def test_non_string_input_returns_none(self):
+        """A malformed theme (hand-edited config or a stale database row)
+        could supply a truthy non-string value; it must be treated as
+        absent rather than raising and 500ing SPA rendering."""
+        assert _svg_to_data_uri(True) is None  # noqa: FBT003
+        assert _svg_to_data_uri(123) is None
+        assert _svg_to_data_uri({"not": "a string"}) is None
+
+
+class TestSpinnerSvgDataUri:
+    """Regression test for #43503: brandSpinnerSvg must reach spa.html only
+    as a base64 data URI, never as raw markup that could be inlined."""
+
+    @patch("superset.views.base.get_spa_payload")
+    @patch("superset.views.base.app")
+    def test_custom_svg_rendered_only_as_data_uri(self, mock_app, mock_payload):
+        from superset.views.base import get_spa_template_context
+
+        mock_app.config = {}
+        malicious_svg = '<svg onload="alert(1)"><script>alert(1)</script></svg>'
+        mock_payload.return_value = {
+            "common": {
+                "theme": {
+                    "default": {
+                        "token": {"brandSpinnerSvg": malicious_svg},
+                    }
+                }
+            }
+        }
+
+        result = get_spa_template_context("app")
+
+        # The template no longer receives a raw "spinner_svg" it could
+        # render with `| safe`; only the encoded data URI is passed.
+        assert "spinner_svg" not in result
+        assert result["spinner_svg_data_uri"].startswith("data:image/svg+xml;base64,")
+        decoded = base64.b64decode(
+            result["spinner_svg_data_uri"].split(",", 1)[1]
+        ).decode("utf-8")
+        assert decoded == malicious_svg
+
+
 class TestThemeCacheInvalidation:
     """Test theme cache invalidation event listeners"""