This is an automated email from the ASF dual-hosted git repository.
alexandrusoare 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 b98bd2a07ac fix(mcp): Block destructive DDL (DROP, TRUNCATE, ALTER) in
execute_sql (#39621)
b98bd2a07ac is described below
commit b98bd2a07ac105c01d0257d01651ab71a24accc9
Author: Alexandru Soare <[email protected]>
AuthorDate: Wed May 20 14:29:15 2026 +0300
fix(mcp): Block destructive DDL (DROP, TRUNCATE, ALTER) in execute_sql
(#39621)
---
superset/mcp_service/sql_lab/tool/execute_sql.py | 50 ++++++-
superset/sql/parse.py | 53 ++++++++
.../mcp_service/sql_lab/tool/test_execute_sql.py | 150 +++++++++++++++++++++
tests/unit_tests/sql/parse_tests.py | 60 +++++++++
4 files changed, 310 insertions(+), 3 deletions(-)
diff --git a/superset/mcp_service/sql_lab/tool/execute_sql.py
b/superset/mcp_service/sql_lab/tool/execute_sql.py
index b5f01e8afa7..1e3dfddf866 100644
--- a/superset/mcp_service/sql_lab/tool/execute_sql.py
+++ b/superset/mcp_service/sql_lab/tool/execute_sql.py
@@ -50,6 +50,7 @@ from superset.mcp_service.utils.oauth2_utils import (
build_oauth2_redirect_message,
OAUTH2_CONFIG_ERROR_MESSAGE,
)
+from superset.sql.parse import SQLScript
logger = logging.getLogger(__name__)
@@ -117,7 +118,50 @@ async def execute_sql(request: ExecuteSqlRequest, ctx:
Context) -> ExecuteSqlRes
error_type=SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR.value,
)
- # 2. Build QueryOptions and execute query
+ # 2. Block destructive DDL (DROP, TRUNCATE, ALTER)
+ # Fail-closed: if parsing fails, block the query rather than
+ # allowing potentially destructive SQL to bypass the check.
+ # Render Jinja2 templates first so templated SQL can be parsed.
+ with event_logger.log_context(action="mcp.execute_sql.ddl_check"):
+ try:
+ sql_to_check = request.sql
+ if request.template_params:
+ from superset.jinja_context import get_template_processor
+
+ tp = get_template_processor(database=database)
+ sql_to_check = tp.process_template(
+ request.sql, **request.template_params
+ )
+
+ script = SQLScript(sql_to_check,
database.db_engine_spec.engine)
+ if script.has_destructive():
+ await ctx.error(
+ "Destructive DDL blocked: sql_preview=%r" % sql_preview
+ )
+ return ExecuteSqlResponse(
+ success=False,
+ error=(
+ "Destructive DDL statements (DROP, TRUNCATE,
ALTER) "
+ "are not allowed through MCP. Use the Superset SQL
"
+ "Lab UI for administrative database operations."
+ ),
+
error_type=SupersetErrorType.DML_NOT_ALLOWED_ERROR.value,
+ )
+ except Exception as parse_err:
+ await ctx.error(
+ "DDL pre-check failed to parse SQL, blocking query: %s"
+ % str(parse_err)
+ )
+ return ExecuteSqlResponse(
+ success=False,
+ error=(
+ "SQL could not be parsed for security validation. "
+ "Please check your SQL syntax and try again."
+ ),
+ error_type=SupersetErrorType.INVALID_SQL_ERROR.value,
+ )
+
+ # 3. Build QueryOptions and execute query
cache_opts = CacheOptions(force_refresh=True) if request.force_refresh
else None
options = QueryOptions(
catalog=request.catalog,
@@ -129,11 +173,11 @@ async def execute_sql(request: ExecuteSqlRequest, ctx:
Context) -> ExecuteSqlRes
cache=cache_opts,
)
- # 3. Execute query
+ # 4. Execute query
with
event_logger.log_context(action="mcp.execute_sql.query_execution"):
result = database.execute(request.sql, options)
- # 4. Convert to MCP response format
+ # 5. Convert to MCP response format
with
event_logger.log_context(action="mcp.execute_sql.response_conversion"):
response = _convert_to_response(result)
diff --git a/superset/sql/parse.py b/superset/sql/parse.py
index aab3a61c50e..77475322d43 100644
--- a/superset/sql/parse.py
+++ b/superset/sql/parse.py
@@ -439,6 +439,14 @@ class BaseSQLStatement(Generic[InternalRepresentation]):
"""
raise NotImplementedError()
+ def is_destructive(self) -> bool:
+ """
+ Check if the statement is destructive DDL (DROP, TRUNCATE, ALTER).
+
+ :return: True if the statement is destructive DDL.
+ """
+ raise NotImplementedError()
+
def optimize(self) -> BaseSQLStatement[InternalRepresentation]:
"""
Return optimized statement.
@@ -719,6 +727,31 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
return False
+ def is_destructive(self) -> bool:
+ """
+ Check if the statement is destructive DDL (DROP, TRUNCATE, ALTER).
+
+ Unlike ``is_mutating()``, this excludes non-destructive DML
+ (INSERT, UPDATE, DELETE, MERGE) and CREATE.
+
+ :return: True if the statement is destructive DDL.
+ """
+ destructive_nodes = (
+ exp.Drop,
+ exp.TruncateTable,
+ exp.Alter,
+ )
+
+ for node_type in destructive_nodes:
+ if self._parsed.find(node_type):
+ return True
+
+ # Handle ALTER parsed as Command (Oracle, MS SQL dialects)
+ if isinstance(self._parsed, exp.Command) and self._parsed.name ==
"ALTER":
+ return True # pragma: no cover
+
+ return False
+
def format(self, comments: bool = True) -> str:
"""
Pretty-format the SQL statement.
@@ -1175,6 +1208,18 @@ class KustoKQLStatement(BaseSQLStatement[str]):
"""
return self._parsed.startswith(".") and not
self._parsed.startswith(".show")
+ def is_destructive(self) -> bool:
+ """
+ Check if the statement is destructive DDL.
+
+ Kusto KQL uses dot-commands for management operations. Destructive
+ operations start with ``.drop`` or ``.alter``.
+
+ :return: True if the statement is destructive DDL.
+ """
+ lower = self._parsed.lower()
+ return lower.startswith(".drop") or lower.startswith(".alter")
+
def optimize(self) -> KustoKQLStatement:
"""
Return optimized statement.
@@ -1321,6 +1366,14 @@ class SQLScript:
"""
return any(statement.is_mutating() for statement in self.statements)
+ def has_destructive(self) -> bool:
+ """
+ Check if the script contains destructive DDL (DROP, TRUNCATE, ALTER).
+
+ :return: True if any statement is destructive DDL.
+ """
+ return any(statement.is_destructive() for statement in self.statements)
+
def optimize(self) -> SQLScript:
"""
Return optimized script.
diff --git a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
index de67736ac55..e495183281b 100644
--- a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
+++ b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
@@ -1317,3 +1317,153 @@ class TestColumnInfoIsNullable:
{"name": "c", "type": "int", "is_nullable": "UNKNOWN"}
)
assert col.is_nullable is None
+
+
+class TestDestructiveDDLBlocking:
+ """Tests for destructive DDL blocking in execute_sql."""
+
+ @pytest.fixture
+ def ddl_mocks(self):
+ """Common mock wiring for DDL blocking tests."""
+ with (
+ patch("superset.db") as mock_db,
+ patch("superset.security_manager") as mock_sm,
+ ):
+ mock_database = _mock_database()
+ mock_database.db_engine_spec.engine = "postgresql"
+ query_chain = mock_db.session.query.return_value
+ query_chain.filter_by.return_value.first.return_value =
mock_database
+ mock_sm.can_access_database.return_value = True
+ yield mock_database
+
+ @pytest.mark.asyncio
+ async def test_drop_table_blocked(self, ddl_mocks, mcp_server):
+ """DROP TABLE is blocked before reaching the executor."""
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "execute_sql",
+ {"request": {"database_id": 1, "sql": "DROP TABLE
birth_names"}},
+ )
+ data = result.structured_content
+ assert data["success"] is False
+ assert "Destructive DDL" in data["error"]
+ assert "DROP" in data["error"]
+ ddl_mocks.execute.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_truncate_blocked(self, ddl_mocks, mcp_server):
+ """TRUNCATE TABLE is blocked before reaching the executor."""
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "execute_sql",
+ {"request": {"database_id": 1, "sql": "TRUNCATE TABLE
birth_names"}},
+ )
+ data = result.structured_content
+ assert data["success"] is False
+ assert "Destructive DDL" in data["error"]
+ ddl_mocks.execute.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_alter_table_blocked(self, ddl_mocks, mcp_server):
+ """ALTER TABLE is blocked before reaching the executor."""
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "execute_sql",
+ {
+ "request": {
+ "database_id": 1,
+ "sql": "ALTER TABLE birth_names ADD COLUMN x INT",
+ }
+ },
+ )
+ data = result.structured_content
+ assert data["success"] is False
+ assert "Destructive DDL" in data["error"]
+ ddl_mocks.execute.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_drop_in_multi_statement_blocked(self, ddl_mocks,
mcp_server):
+ """DROP TABLE hidden in a multi-statement query is blocked."""
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "execute_sql",
+ {
+ "request": {
+ "database_id": 1,
+ "sql": "DROP TABLE birth_names; SELECT 1",
+ }
+ },
+ )
+ data = result.structured_content
+ assert data["success"] is False
+ assert "Destructive DDL" in data["error"]
+ ddl_mocks.execute.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_select_allowed(self, ddl_mocks, mcp_server):
+ """SELECT queries pass through the DDL check."""
+ ddl_mocks.execute.return_value = _create_select_result(
+ rows=[{"x": 1}], columns=["x"], original_sql="SELECT 1"
+ )
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "execute_sql",
+ {"request": {"database_id": 1, "sql": "SELECT 1"}},
+ )
+ data = result.structured_content
+ assert data["success"] is True
+ ddl_mocks.execute.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_insert_allowed(self, ddl_mocks, mcp_server):
+ """INSERT queries pass through the DDL check (DML is allowed)."""
+ ddl_mocks.execute.return_value = _create_dml_result(
+ affected_rows=1,
+ original_sql="INSERT INTO t VALUES (1)",
+ )
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "execute_sql",
+ {"request": {"database_id": 1, "sql": "INSERT INTO t VALUES
(1)"}},
+ )
+ data = result.structured_content
+ assert data["success"] is True
+ ddl_mocks.execute.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_parse_failure_blocks_query(self, ddl_mocks, mcp_server):
+ """When SQL parsing fails, the query is blocked (fail-closed)."""
+ import sys
+
+ execute_sql_mod =
sys.modules["superset.mcp_service.sql_lab.tool.execute_sql"]
+ with patch.object(
+ execute_sql_mod,
+ "SQLScript",
+ side_effect=Exception("parse error"),
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "execute_sql",
+ {"request": {"database_id": 1, "sql": "DROP TABLE
birth_names"}},
+ )
+ data = result.structured_content
+ assert data["success"] is False
+ assert "could not be parsed" in data["error"]
+ ddl_mocks.execute.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_drop_table_blocked_mysql(self, ddl_mocks, mcp_server):
+ """DROP TABLE is blocked for non-PostgreSQL dialects too."""
+ ddl_mocks.db_engine_spec.engine = "mysql"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "execute_sql",
+ {"request": {"database_id": 1, "sql": "DROP TABLE users"}},
+ )
+ data = result.structured_content
+ assert data["success"] is False
+ assert "Destructive DDL" in data["error"]
+ ddl_mocks.execute.assert_not_called()
diff --git a/tests/unit_tests/sql/parse_tests.py
b/tests/unit_tests/sql/parse_tests.py
index a2361ae5abc..bbf4ec2e527 100644
--- a/tests/unit_tests/sql/parse_tests.py
+++ b/tests/unit_tests/sql/parse_tests.py
@@ -1327,6 +1327,66 @@ def test_is_mutating_anonymous_block(sql: str, expected:
bool) -> None:
assert SQLStatement(sql, "postgresql").is_mutating() == expected
[email protected](
+ "sql, expected",
+ [
+ ("SELECT 1", False),
+ ("INSERT INTO t VALUES (1)", False),
+ ("UPDATE t SET x = 1", False),
+ ("DELETE FROM t", False),
+ ("MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DELETE",
False),
+ ("CREATE TABLE t (id INT)", False),
+ ("DROP TABLE t", True),
+ ("DROP TABLE IF EXISTS t", True),
+ ("DROP VIEW v", True),
+ ("TRUNCATE TABLE t", True),
+ ("ALTER TABLE t ADD COLUMN x INT", True),
+ ("ALTER TABLE t DROP COLUMN x", True),
+ ],
+)
+def test_is_destructive(sql: str, expected: bool) -> None:
+ """
+ Test that ``is_destructive`` detects DROP, TRUNCATE, and ALTER
+ but not SELECT, INSERT, UPDATE, DELETE, MERGE, or CREATE.
+ """
+ assert SQLStatement(sql, "postgresql").is_destructive() == expected
+
+
[email protected](
+ "sql, expected",
+ [
+ ("SELECT 1; INSERT INTO t VALUES (1)", False),
+ ("SELECT 1; DROP TABLE t", True),
+ ("SELECT 1; TRUNCATE TABLE t", True),
+ ("CREATE TABLE t (id INT); ALTER TABLE t ADD COLUMN x INT", True),
+ ],
+)
+def test_has_destructive(sql: str, expected: bool) -> None:
+ """
+ Test that ``has_destructive`` on SQLScript detects destructive DDL
+ across multiple statements.
+ """
+ assert SQLScript(sql, "postgresql").has_destructive() == expected
+
+
[email protected](
+ "kql, expected",
+ [
+ (".drop table T", True),
+ (".alter table T (col:string)", True),
+ (".show tables", False),
+ ("T | count", False),
+ ],
+)
+def test_kusto_is_destructive(kql: str, expected: bool) -> None:
+ """
+ Test ``is_destructive`` on KustoKQLStatement.
+ """
+ from superset.sql.parse import KustoKQLStatement
+
+ assert KustoKQLStatement(kql, "kustokql").is_destructive() == expected
+
+
def test_optimize() -> None:
"""
Test that the `optimize` method works as expected.