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/06/02 03:37:25 UTC

(superset) 01/01: feat(extensions): add EXTENSION_BLOCKLIST to refuse loading extensions

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

rusackas pushed a commit to branch feat/extension-blocklist
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 677c7c7a2e9b957e57e5e13c66be51addd4fd506
Author: Claude Code <no...@anthropic.com>
AuthorDate: Mon Jun 1 20:37:04 2026 -0700

    feat(extensions): add EXTENSION_BLOCKLIST to refuse loading extensions
    
    Extensions execute arbitrary Python; once a valid .supx/local extension is
    discovered it is loaded unconditionally. Add an EXTENSION_BLOCKLIST config so
    operators can refuse a specific extension (by id, blocking all versions) or a
    specific version ("<id>@<version>"), even if present in LOCAL_EXTENSIONS or
    EXTENSIONS_PATH. Blocked extensions are skipped with a warning in both load
    paths.
    
    This is the self-contained slice of the extension supply-chain hardening; a
    full vulnerability-database / dependency-audit integration is out of scope.
    
    Add a unit test for is_extension_blocked (id and id@version matching).
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 superset/config.py                        |  5 ++++
 superset/extensions/utils.py              | 32 ++++++++++++++++++++
 tests/unit_tests/extensions/test_utils.py | 50 +++++++++++++++++++++++++++++++
 3 files changed, 87 insertions(+)

diff --git a/superset/config.py b/superset/config.py
index 2a29ce87147..52e471b6388 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -2525,6 +2525,11 @@ except ImportError:
 
 LOCAL_EXTENSIONS: list[str] = []
 EXTENSIONS_PATH: str | None = None
+# Extensions that must not be loaded, even if present in LOCAL_EXTENSIONS or
+# EXTENSIONS_PATH. Each entry is an extension id (blocks every version) or
+# "<id>@<version>" (blocks a specific version). Use this to disable an
+# extension found to be vulnerable or otherwise undesirable.
+EXTENSION_BLOCKLIST: list[str] = []
 
 # Default polling interval for tasks (seconds)
 TASK_ABORT_POLLING_DEFAULT_INTERVAL = 10
diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py
index 6332c97435d..53d791f6804 100644
--- a/superset/extensions/utils.py
+++ b/superset/extensions/utils.py
@@ -254,6 +254,20 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
     return extension_data
 
 
+def is_extension_blocked(extension: LoadedExtension) -> bool:
+    """
+    Return True if the extension is denied by the ``EXTENSION_BLOCKLIST`` config.
+
+    Each blocklist entry is either an extension id (blocks every version of that
+    extension) or ``"<id>@<version>"`` (blocks only that exact version).
+    """
+    blocklist = set(current_app.config.get("EXTENSION_BLOCKLIST") or [])
+    if not blocklist:
+        return False
+    manifest = extension.manifest
+    return manifest.id in blocklist or f"{manifest.id}@{manifest.version}" in blocklist
+
+
 def get_extensions() -> dict[str, LoadedExtension]:
     extensions: dict[str, LoadedExtension] = {}
 
@@ -265,6 +279,15 @@ def get_extensions() -> dict[str, LoadedExtension]:
             abs_dist_path = str((Path(path) / "dist").resolve())
             extension = get_loaded_extension(files, source_base_path=abs_dist_path)
             extension_id = extension.manifest.id
+            if is_extension_blocked(extension):
+                logger.warning(
+                    "Refusing to load blocklisted extension %s (ID: %s, "
+                    "version: %s) from local filesystem",
+                    extension.name,
+                    extension_id,
+                    extension.manifest.version,
+                )
+                continue
             extensions[extension_id] = extension
             logger.info(
                 "Loading extension %s (ID: %s) from local filesystem",
@@ -280,6 +303,15 @@ def get_extensions() -> dict[str, LoadedExtension]:
 
         for extension in discover_and_load_extensions(extensions_path):
             extension_id = extension.manifest.id
+            if is_extension_blocked(extension):
+                logger.warning(
+                    "Refusing to load blocklisted extension %s (ID: %s, "
+                    "version: %s) from discovery path",
+                    extension.name,
+                    extension_id,
+                    extension.manifest.version,
+                )
+                continue
             if extension_id not in extensions:  # Don't override LOCAL_EXTENSIONS
                 extensions[extension_id] = extension
                 logger.info(
diff --git a/tests/unit_tests/extensions/test_utils.py b/tests/unit_tests/extensions/test_utils.py
new file mode 100644
index 00000000000..545c7cee800
--- /dev/null
+++ b/tests/unit_tests/extensions/test_utils.py
@@ -0,0 +1,50 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from unittest.mock import MagicMock
+
+from flask import current_app
+
+from superset.extensions.utils import is_extension_blocked
+
+
+def _extension(ext_id: str, version: str) -> MagicMock:
+    extension = MagicMock()
+    extension.manifest.id = ext_id
+    extension.manifest.version = version
+    return extension
+
+
+def test_is_extension_blocked() -> None:
+    original = current_app.config.get("EXTENSION_BLOCKLIST")
+    try:
+        # Empty blocklist: nothing is blocked.
+        current_app.config["EXTENSION_BLOCKLIST"] = []
+        assert is_extension_blocked(_extension("acme.widget", "1.0.0")) is False
+
+        # Block by id: every version of that id is blocked.
+        current_app.config["EXTENSION_BLOCKLIST"] = ["acme.widget"]
+        assert is_extension_blocked(_extension("acme.widget", "1.0.0")) is True
+        assert is_extension_blocked(_extension("acme.widget", "2.0.0")) is True
+        assert is_extension_blocked(_extension("acme.other", "1.0.0")) is False
+
+        # Block by id@version: only that exact version is blocked.
+        current_app.config["EXTENSION_BLOCKLIST"] = ["acme.widget@1.0.0"]
+        assert is_extension_blocked(_extension("acme.widget", "1.0.0")) is True
+        assert is_extension_blocked(_extension("acme.widget", "2.0.0")) is False
+    finally:
+        current_app.config["EXTENSION_BLOCKLIST"] = original