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 f02f53df234 fix(sql): reject client-side file-transfer statements in
query execution (#44496)
f02f53df234 is described below
commit f02f53df2342f639ce4f93f277e82f1362c0b3e7
Author: Shaitan <[email protected]>
AuthorDate: Thu Oct 1 03:37:23 2026 +0100
fix(sql): reject client-side file-transfer statements in query execution
(#44496)
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Amin Ghadersohi <[email protected]>
Co-authored-by: Evan Rusackas <[email protected]>
Co-authored-by: Evan Rusackas <[email protected]>
---
UPDATING.md | 9 +
superset/commands/report/alert.py | 12 +-
superset/commands/sql_lab/estimate.py | 10 +-
superset/exceptions.py | 18 ++
superset/sql/execution/executor.py | 13 +
superset/sql/parse.py | 333 ++++++++++++++++++++-
superset/sql_lab.py | 5 +
superset/translations/messages.pot | 4 +
tests/unit_tests/commands/report/alert_test.py | 37 +++
tests/unit_tests/commands/sql_lab/test_estimate.py | 30 ++
tests/unit_tests/sql/execution/test_executor.py | 51 ++++
tests/unit_tests/sql/parse_tests.py | 207 +++++++++++++
tests/unit_tests/sql_lab_test.py | 39 +++
13 files changed, 756 insertions(+), 12 deletions(-)
diff --git a/UPDATING.md b/UPDATING.md
index 1b60597110f..da66b164f96 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -308,6 +308,15 @@ upgrading. See the two migrations' docstrings
(`superset/migrations/versions/
`..._00-01_3ce9a4572f8a_rename_deprecated_permissions_33272.py`) for the full
per-permission mapping and reasoning.
+- Snowflake stage file-management statements (`PUT`, `GET`, `REMOVE` and its
+ `RM` alias) are rejected in user-submitted SQL (SQL Lab, the cost-estimate
+ path and alert queries), regardless of the database's `allow_dml` setting.
+ `PUT`/`GET` perform file I/O on the host running the
+ query, and `REMOVE`/`RM` delete files within a stage; none of them read or
+ write table data, so `allow_dml` does not govern them. Deployments that ran
+ these through SQL Lab should manage stage files with Snowflake's own clients
+ instead. `LIST`/`LS`, which only enumerate staged files, are unaffected.
+
### MySQL metadata database now actually defaults to READ COMMITTED
Superset has always *intended* to default the metadata-database isolation
diff --git a/superset/commands/report/alert.py
b/superset/commands/report/alert.py
index a8927ad22f7..8bf8f1e2a07 100644
--- a/superset/commands/report/alert.py
+++ b/superset/commands/report/alert.py
@@ -186,12 +186,22 @@ class AlertCommand(BaseCommand):
def _validate_rendered_sql(self, rendered_sql: str) -> None:
"""
Enforce SQL-level constraints on the rendered alert query: a single
- statement, and no mutations unless the database allows DML.
+ statement, no client-side file transfer, and no mutations unless the
+ database allows DML.
"""
database = self._report_schedule.database
script = SQLScript(rendered_sql, engine=database.backend)
if len(script.statements) != 1:
raise AlertQueryError(message=_("Alert query must be a single
statement"))
+ # Rejected regardless of `allow_dml`: these do host file I/O, not DML.
+ if commands := script.get_client_file_transfer_commands():
+ raise AlertQueryError(
+ message=_(
+ "Alert query must not contain the file-transfer "
+ "command(s): %(commands)s",
+ commands=", ".join(commands),
+ )
+ )
if script.has_mutation() and not database.allow_dml:
raise AlertQueryError(message=_("Alert query must be read-only"))
diff --git a/superset/commands/sql_lab/estimate.py
b/superset/commands/sql_lab/estimate.py
index 2a5963169e7..abd3e137fd4 100644
--- a/superset/commands/sql_lab/estimate.py
+++ b/superset/commands/sql_lab/estimate.py
@@ -29,6 +29,7 @@ from superset.daos.database import DatabaseDAO
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import (
OAuth2RedirectError,
+ SupersetDisallowedClientFileTransferException,
SupersetDisallowedSQLFunctionException,
SupersetDisallowedSQLTableException,
SupersetDMLNotAllowedException,
@@ -94,8 +95,9 @@ class QueryEstimationCommand(BaseCommand):
)
def _apply_sql_security(self, sql: str) -> str:
- """Run the disallowed-function/table, DML and RLS controls against the
- SQL to be estimated, mirroring ``sql_lab.execute_sql_statements``.
+ """Run the disallowed-function/table, file-transfer, DML and RLS
controls
+ against the SQL to be estimated, mirroring
+ ``sql_lab.execute_sql_statements``.
Returns the SQL with RLS predicates injected (when ``RLS_IN_SQLLAB`` is
enabled), so the cost estimate reflects the same constrained query the
@@ -148,6 +150,10 @@ class QueryEstimationCommand(BaseCommand):
if found_tables:
raise SupersetDisallowedSQLTableException(found_tables)
+ # Rejected regardless of `allow_dml`: these do host file I/O, not DML.
+ if file_transfer_commands :=
parsed_script.get_client_file_transfer_commands():
+ raise
SupersetDisallowedClientFileTransferException(file_transfer_commands)
+
if parsed_script.has_mutation() and not self._database.allow_dml:
raise SupersetDMLNotAllowedException()
diff --git a/superset/exceptions.py b/superset/exceptions.py
index 86d950da3a7..9280d12ca36 100644
--- a/superset/exceptions.py
+++ b/superset/exceptions.py
@@ -492,6 +492,24 @@ class
SupersetDisallowedSQLTableException(SupersetErrorException):
)
+class SupersetDisallowedClientFileTransferException(SupersetErrorException):
+ """
+ Client-side file-transfer command found in SQL statement
+ """
+
+ def __init__(self, commands: list[str]):
+ super().__init__(
+ SupersetError(
+ message=(
+ "SQL statement contains disallowed client-side "
+ f"file-transfer command(s): {', '.join(commands)}"
+ ),
+ error_type=SupersetErrorType.SYNTAX_ERROR,
+ level=ErrorLevel.ERROR,
+ )
+ )
+
+
class AcquireDistributedLockFailedException(Exception): # noqa: N818
"""
Exception to signalize failure to acquire lock.
diff --git a/superset/sql/execution/executor.py
b/superset/sql/execution/executor.py
index e357d396216..bc235fda30e 100644
--- a/superset/sql/execution/executor.py
+++ b/superset/sql/execution/executor.py
@@ -604,6 +604,19 @@ class SQLExecutor:
)
)
+ # Rejected regardless of `allow_dml`: these do host file I/O, not DML.
+ if file_transfer_commands :=
script.get_client_file_transfer_commands():
+ raise SupersetSecurityException(
+ SupersetError(
+ message=(
+ "Disallowed client-side file-transfer command(s): "
+ f"{', '.join(file_transfer_commands)}"
+ ),
+ error_type=SupersetErrorType.INVALID_SQL_ERROR,
+ level=ErrorLevel.ERROR,
+ )
+ )
+
# Check DML permission
if script.has_mutation() and not self.database.allow_dml:
raise SupersetSecurityException(
diff --git a/superset/sql/parse.py b/superset/sql/parse.py
index 667b818e94c..5028f8dab05 100644
--- a/superset/sql/parse.py
+++ b/superset/sql/parse.py
@@ -24,7 +24,7 @@ import re
import urllib.parse
from collections.abc import Callable
from dataclasses import dataclass
-from functools import lru_cache
+from functools import cached_property, lru_cache
from typing import Any, Generic, Optional, TYPE_CHECKING, TypeVar
import sqlglot
@@ -37,6 +37,7 @@ from sqlglot.dialects.dialect import (
DialectType,
NormalizationStrategy,
)
+from sqlglot.dialects.mysql import MySQL
from sqlglot.dialects.singlestore import SingleStore
from sqlglot.errors import OptimizeError, ParseError
from sqlglot.generator import Generator
@@ -652,6 +653,16 @@ class BaseSQLStatement(Generic[InternalRepresentation]):
"""
raise NotImplementedError()
+ def get_client_file_transfer_command(self) -> str | None:
+ """
+ Return the client-side file-transfer command head, if this is one.
+
+ Defaults to ``None``; engines that have such commands override this.
+
+ :return: The uppercased command head (e.g. ``"PUT"``), else ``None``.
+ """
+ return None
+
def optimize(self) -> BaseSQLStatement[InternalRepresentation]:
"""
Return optimized statement.
@@ -888,6 +899,97 @@ def _find_last_token_node(node: exp.Expression) ->
exp.Expression:
return node
+# Alternation ordered so a quoted region is consumed whole before either
+# comment form can match inside it, keeping a `--` or `/*` that is merely
+# part of a literal from truncating the text after it. Each of the two
+# regions that scan forward for a closing delimiter -- the dollar-quoted
+# literal and the block comment -- needs a trailing "runs to end"
+# alternative for the unterminated case: without one the lazy `.*?` rescans
+# to end of text from every opener in turn, which is quadratic on input a
+# user controls. The unterminated literal stays inside the `literal` group
+# so it is preserved rather than blanked, since text a gate would match must
+# not disappear.
+_COMMENT_RE_TEMPLATE = r"""
+ (?P<literal>
+ {single_quoted} # single-quoted string
+ | {double_quoted} # double-quoted identifier
+ | \$(?P<tag>\w*)\$.*?\$(?P=tag)\$ # dollar-quoted string
+ | \$\w*\$.* # unterminated: runs to end
+ )
+ | {line_comment}[^\n]* # line comment
+ | /\*.*?\*/ # block comment
+ | /\*.* # unterminated: runs to end
+ """
+
+
+def _quoted_literal_pattern(quote: str, backslash_escapes: bool) -> str:
+ """
+ Build the pattern matching one quoted region, delimiters included.
+
+ The doubled delimiter (``'it''s'``) always escapes. A backslash only
+ escapes on the dialects that say so, and where it does it must be
+ recognised: without it the scan ends the literal at the escaped quote of
+ ``'it\\'s -- x'``, reads the rest as code, and lets the ``--`` blank out
+ text the server actually runs, hiding it from every gate that scans here.
+
+ The alternatives are kept mutually exclusive -- the negated class drops
+ the escape character rather than overlapping with it -- so exactly one
+ branch can start at any position. An overlapping form would let a run of
+ backslashes with no closing quote backtrack exponentially, on text a user
+ controls.
+
+ :param quote: The delimiter character
+ :param backslash_escapes: Whether a backslash escapes on this dialect
+ :return: The pattern for one quoted region
+ """
+ if backslash_escapes:
+ return rf"{quote}(?:[^{quote}\\]|\\.|{quote}{quote})*{quote}"
+ return rf"{quote}(?:[^{quote}]|{quote}{quote})*{quote}"
+
+
+@lru_cache(maxsize=None)
+def _comment_re_for(dialect: Dialects | None) -> re.Pattern[str]:
+ """
+ Return the comment pattern a dialect's raw statement text is lexed with.
+
+ Both rules that vary are read off sqlglot rather than listed here, so a
+ dialect cannot be lexed with the wrong one because a list was not kept in
+ step: the MySQL family is identified by subclassing (``Doris``,
+ ``StarRocks`` and ``SingleStore`` all subclass ``MySQL``, as does this
+ repo's own ``Pinot``), and backslash escaping is read off the tokenizer.
+
+ MySQL-family engines only start a comment on ``--`` when whitespace (or
+ end of line) follows: ``1--2`` is arithmetic there. Stripping it as a
+ comment would delete text the server executes and blind every gate that
+ scans this body, so those dialects get the stricter rule.
+
+ An unknown dialect is lexed as if backslashes escape. That direction is
+ the safe one: treating an escape that is not one only ever folds more
+ text into a literal, which is preserved and still scanned, while missing
+ a real escape blanks executable text.
+
+ Cached because the answer depends only on the dialect, so a script would
+ otherwise re-derive it, and instantiate a sqlglot ``Dialect``, per
+ statement.
+
+ :param dialect: The statement's sqlglot dialect, or ``None`` when unknown
+ :return: The compiled comment pattern
+ """
+ dialect_cls = Dialect.get_or_raise(dialect) if dialect else None
+ backslash_escapes = (
+ dialect_cls is None or "\\" in
dialect_cls.tokenizer_class.STRING_ESCAPES
+ )
+ line_comment = r"--(?=[ \t\r\n]|$)" if isinstance(dialect_cls, MySQL) else
"--"
+ return re.compile(
+ _COMMENT_RE_TEMPLATE.format(
+ single_quoted=_quoted_literal_pattern("'", backslash_escapes),
+ double_quoted=_quoted_literal_pattern('"', backslash_escapes),
+ line_comment=line_comment,
+ ),
+ re.DOTALL | re.VERBOSE,
+ )
+
+
class SQLStatement(BaseSQLStatement[exp.Expression]):
"""
A SQL statement.
@@ -1003,6 +1105,41 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
}
)
+ # Stage file-management heads: ``PUT``/``GET`` move files between the host
+ # running the query and a stage, so they do host file I/O; ``REMOVE`` and
+ # its ``RM`` alias delete files within the stage. None read or write table
+ # data. This is Snowflake syntax but is matched on every dialect, since the
+ # heads are not valid statements elsewhere: a global list cannot reject a
+ # real query, while scoping it to one dialect would let
Snowflake-compatible
+ # engines through.
+ _CLIENT_FILE_TRANSFER_COMMAND_NAMES: frozenset[str] = frozenset(
+ {"PUT", "GET", "REMOVE", "RM"}
+ )
+
+ # The same heads inside a nested body, which cannot be re-parsed and so is
+ # matched on raw text. A stage (``@``) or a ``file://`` URL has to follow
+ # the head, since these words are ordinary identifiers elsewhere and a bare
+ # keyword match would flag a body merely selecting a column named
+ # ``remove``. A run of quotes is skipped rather than exactly one: a body
+ # nested inside a string literal carries its own quotes doubled
+ # (``EXECUTE IMMEDIATE 'PUT ''file://...'''``).
+ _CLIENT_FILE_TRANSFER_NESTED_BODY_RE = re.compile(
+ rf"\b({'|'.join(sorted(_CLIENT_FILE_TRANSFER_COMMAND_NAMES))})"
+ r"""\s+['"]*(?:@|file://)""",
+ re.IGNORECASE,
+ )
+
+ # Opening delimiter of a dollar-quoted region, e.g. `$$` or `$tag$`.
+ _DOLLAR_QUOTE_OPEN_RE = re.compile(r"\$\w*\$")
+
+ # A literal nests once per level of dynamic-SQL indirection (an
+ # `EXECUTE IMMEDIATE` inside an `EXECUTE IMMEDIATE`), so a handful covers
+ # every form that actually executes. The bound is what stops a body of
+ # deeply nested `$tag$` regions, whose nesting a user controls, from
+ # recursing once per level and exhausting the stack; past it the text is
+ # left as found, which keeps it visible to the gates rather than removed.
+ _MAX_LITERAL_NESTING = 32
+
# Command-fallback heads that are only mutating on dialects where the
# structured form (`exp.Set`) is reserved for benign session variables,
# so the opaque-Command fallback is reached exclusively by the dangerous
@@ -1173,20 +1310,157 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
parsed = self._parsed
return parsed.name.upper() if isinstance(parsed, exp.Command) else None
+ @cached_property
def _nested_body_text(self) -> str | None:
"""
Return the raw body of a command that carries a statement as text.
The body of a :attr:`_NESTED_BODY_COMMAND_NAMES` command is invisible
to node-type matching and cannot be re-parsed, so gates that inspect
- the tree fall back to scanning this text.
+ the tree fall back to scanning this text. Comments are stripped here
+ rather than by each caller, so every such gate scans the same text;
+ see :meth:`_strip_comments` for why.
+
+ Cached because three gates ask for the same body in a single request,
+ one of them twice, so the strip would otherwise run about five times
+ over identical text. Caching is safe
+ because the two methods that rebuild ``_parsed`` in place cannot reach
a
+ statement this returns text for: ``set_limit`` returns early unless the
+ node is an ``exp.Query``, and ``remove_unbounded_top_level_order_by``
+ needs an ``order`` argument, while a nested body is only ever carried
by
+ an opaque ``exp.Command``, which is neither.
+
+ :return: The body text with comments removed, or ``None`` when this
+ statement does not carry a nested body
+ """
+ if (head := self._command_head()) not in
self._NESTED_BODY_COMMAND_NAMES:
+ return None
+ body = self._parsed.expression
+ # sqlglot keeps the body as a literal node, whose `str()` re-renders it
+ # as a quoted SQL string: the whole body gets wrapped in quotes and the
+ # quotes inside it are doubled. Reading the value off the node yields
+ # the body as written, so a scan sees the same text the server runs.
+ text = body.name if isinstance(body, exp.Literal) else str(body)
+ # A `DO $$ ... $$` body arrives with its dollar-quote wrapper attached.
+ # That wrapper is statement syntax, not a literal -- the code inside it
+ # runs -- so it is peeled off before the strip, leaving the text either
+ # side of it in place. Any `$tag$` region still nested inside really is
+ # a literal, and is preserved as one.
+ text = self._peel_dollar_quote(text, head)
+ return self._strip_comments(text)
- :return: The raw body text, or ``None`` when this statement does not
- carry a nested body
+ @property
+ def _comment_re(self) -> re.Pattern[str]:
"""
- if self._command_head() not in self._NESTED_BODY_COMMAND_NAMES:
- return None
- return str(self._parsed.expression)
+ Return the comment pattern this statement's dialect is lexed with.
+
+ :return: The compiled comment pattern for this statement's dialect
+ """
+ return _comment_re_for(self._dialect)
+
+ @classmethod
+ def _peel_dollar_quote(cls, text: str, head: str | None) -> str:
+ """
+ Remove the delimiters of the body's dollar-quoted code wrapper, keeping
+ its contents and the text either side of it.
+
+ Only a region that the head itself introduces is a wrapper: a ``DO``
+ block, which takes nothing else, or a region running to the end of the
+ body (``EXECUTE IMMEDIATE $$...$$``). A region that stops short of the
+ end is one argument among several (``CALL p($q$...$q$, ...)``) and so
is
+ an ordinary literal; unwrapping that would expose its contents as code,
+ letting a ``--`` inside it comment out the rest of the body and blind
+ every gate that scans this text.
+
+ Matching the closing delimiter by search rather than by backtracking
+ regex keeps this linear: a pattern that scans forward for a closer
+ rescans to end of text from every opener that has none, which is
+ quadratic on input a user controls.
+
+ :param text: The raw body text to peel
+ :param head: The statement's command head, which decides whether a
+ region that stops short of the end is a wrapper or an argument
+ :return: The text with the wrapper's delimiters removed, or unchanged
+ when the body has no dollar-quoted wrapper
+ """
+ if not (opener := cls._DOLLAR_QUOTE_OPEN_RE.search(text)):
+ return text
+ delimiter = opener.group()
+ closer = text.find(delimiter, opener.end())
+ if closer == -1:
+ return text
+ if head != "DO" and text[closer + len(delimiter) :].strip():
+ return text
+ return (
+ text[: opener.start()]
+ + text[opener.end() : closer]
+ + text[closer + len(delimiter) :]
+ )
+
+ @staticmethod
+ def _split_literal(literal: str) -> tuple[str, str, str]:
+ """
+ Split a matched literal into its delimiters and the text between them.
+
+ :param literal: The literal as matched, delimiters included
+ :return: The opening delimiter, the interior, and the closing
+ delimiter, which is empty when the literal is unterminated
+ """
+ if not literal.startswith("$"):
+ # `'...'` and `"..."` both delimit with a single character.
+ return literal[0], literal[1:-1], literal[-1]
+ delimiter = literal[: literal.index("$", 1) + 1]
+ interior = literal[len(delimiter) :]
+ if not interior.endswith(delimiter):
+ return delimiter, interior, ""
+ return delimiter, interior[: -len(delimiter)], delimiter
+
+ def _strip_comments(self, text: str, depth: int = 0) -> str:
+ """
+ Blank out SQL comments in raw statement text, preserving literals.
+
+ Commented-out code never runs, so no gate should classify on it.
+ Literals are deliberately kept: a nested body runs its dynamic SQL out
+ of a literal (``EXECUTE IMMEDIATE '...'``), so dropping them would
+ blind such a scan to the very form it exists to catch.
+
+ That same reason makes the text *inside* a literal executable, so its
+ comments are stripped too, one literal at a time. Rescanning only the
+ interior is what keeps that safe: a `--` inside a literal can blank out
+ the rest of that literal, but never reaches past the closing delimiter
+ to truncate the statements after it. Without this a comment wedged into
+ a quoted body (``EXECUTE IMMEDIATE 'PUT/**/file:///a @s'``) would split
+ a head from its argument and hide it from every gate that scans here,
+ while the dollar-quoted spelling of the same statement was caught.
+
+ :param text: The raw statement text to scan
+ :param depth: How many levels of literal this text is already inside
+ :return: The text with each comment replaced by a single space, so
+ tokens either side of a removed comment stay separated
+ """
+ # Text carrying neither comment opener has nothing to strip, and the
+ # substitution below would be an identity: no comment branch can match,
+ # and every literal branch returns the literal unchanged. This is the
+ # same test `replace` already applies per literal, hoisted to the whole
+ # scan so the common comment-free body skips the pass entirely.
+ if "--" not in text and "/*" not in text:
+ return text
+
+ def replace(match: re.Match[str]) -> str:
+ if (literal := match.group("literal")) is None:
+ return " "
+ # A literal carrying neither comment opener has nothing to strip,
+ # and descending anyway costs a full scan per literal on text a
+ # user controls: a body of nothing but quotes is one recursion per
+ # `''` pair, which is ~20x the per-character cost of ordinary text.
+ if depth >= self._MAX_LITERAL_NESTING or (
+ "--" not in literal and "/*" not in literal
+ ):
+ return literal
+ opening, interior, closing = self._split_literal(literal)
+ return opening + self._strip_comments(interior, depth + 1) +
closing
+
+ return self._comment_re.sub(replace, text)
def _explain_analyze_body(self) -> str | None:
"""
@@ -1396,6 +1670,28 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
return False
+ def get_client_file_transfer_command(self) -> str | None:
+ """
+ Return the client-side file-transfer command head, if this is one.
+
+ :return: The uppercased command head (e.g. ``"PUT"``), else ``None``.
+ """
+ # Three shapes to cover: sqlglot models only the quoted-path forms
+ # structurally (``PUT 'file://...' @s`` -> ``exp.Put``, whose ``key``
is
+ # the head lowercased); every other form falls back to an opaque
+ # ``exp.Command``; and a nested body executes for real yet is invisible
+ # to both, so it is scanned as raw text, as ``changes_search_path``
+ # does for its own forms.
+ if isinstance(self._parsed, (exp.Put, exp.Get)):
+ return self._parsed.key.upper()
+ if (head := self._command_head()) in
self._CLIENT_FILE_TRANSFER_COMMAND_NAMES:
+ return head
+ if (body := self._nested_body_text) and (
+ match := self._CLIENT_FILE_TRANSFER_NESTED_BODY_RE.search(body)
+ ):
+ return match.group(1).upper()
+ return None
+
def is_destructive(self) -> bool:
"""
Check if the statement is destructive DDL (DROP, TRUNCATE, ALTER).
@@ -1617,7 +1913,7 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
# because a computed setting name never spells the latter
# contiguously. Whole-word matching keeps an unrelated identifier that
# merely embeds one of them (`reset_config`) from being flagged.
- body = self._nested_body_text()
+ body = self._nested_body_text
return bool(
body and re.search(r"\b(search_path|set_config)\b", body,
re.IGNORECASE)
)
@@ -1662,7 +1958,7 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
# required: a body that merely creates or references one is not a
# rebind. The optional qualifier mirrors the tokens the non-nested
# path strips, so `SET LOCAL SCHEMA` is matched in either position.
- body = self._nested_body_text()
+ body = self._nested_body_text
if body and re.search(
r"\bset\s+(?:(?:session|local|current)\s+)?(?:schema|catalog)\b",
body,
@@ -2514,6 +2810,25 @@ class SQLScript:
"""
return any(statement.is_mutating() for statement in self.statements)
+ def get_client_file_transfer_commands(self) -> list[str]:
+ """
+ Return the client-side file-transfer command heads in the script.
+
+ Sorted here so that every caller renders the heads in the same order:
+ the set these are deduplicated into iterates arbitrarily, which would
+ otherwise leave each error message to remember to sort for itself.
+
+ :return: The sorted, deduplicated uppercased command heads found
+ (empty when none).
+ """
+ return sorted(
+ {
+ command
+ for statement in self.statements
+ if (command := statement.get_client_file_transfer_command())
is not None
+ }
+ )
+
def has_destructive(self) -> bool:
"""
Check if the script contains destructive DDL (DROP, TRUNCATE, ALTER).
diff --git a/superset/sql_lab.py b/superset/sql_lab.py
index dfb3124948f..4fe37ccb983 100644
--- a/superset/sql_lab.py
+++ b/superset/sql_lab.py
@@ -49,6 +49,7 @@ from superset.db_engine_specs import BaseEngineSpec
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import (
OAuth2RedirectError,
+ SupersetDisallowedClientFileTransferException,
SupersetDisallowedSQLFunctionException,
SupersetDisallowedSQLTableException,
SupersetDMLNotAllowedException,
@@ -518,6 +519,10 @@ def execute_sql_statements( # noqa: C901
if found_tables:
raise SupersetDisallowedSQLTableException(found_tables)
+ # Rejected regardless of `allow_dml`: these do host file I/O, not DML.
+ if file_transfer_commands :=
parsed_script.get_client_file_transfer_commands():
+ raise
SupersetDisallowedClientFileTransferException(file_transfer_commands)
+
if parsed_script.has_mutation() and not database.allow_dml:
raise SupersetDMLNotAllowedException()
diff --git a/superset/translations/messages.pot
b/superset/translations/messages.pot
index 2b228252fba..e38a61d782f 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -1483,6 +1483,10 @@ msgstr ""
msgid "Alert query must be read-only"
msgstr ""
+#, python-format
+msgid "Alert query must not contain the file-transfer command(s): %(commands)s"
+msgstr ""
+
msgid "Alert query returned a non-number value."
msgstr ""
diff --git a/tests/unit_tests/commands/report/alert_test.py
b/tests/unit_tests/commands/report/alert_test.py
index 13164230006..cea80fff22b 100644
--- a/tests/unit_tests/commands/report/alert_test.py
+++ b/tests/unit_tests/commands/report/alert_test.py
@@ -569,3 +569,40 @@ def test_execute_query_wraps_template_rendering_error(
with pytest.raises(AlertQueryError):
command._execute_query()
+
+
[email protected]("allow_dml", [False, True])
[email protected](
+ "sql, rejected_head",
+ [
+ ("PUT file:///tmp/data.csv @my_stage", "PUT"),
+ ("SELECT value FROM metrics", None),
+ ],
+)
+def test_validate_rendered_sql_client_file_transfer_gate(
+ mocker: MockerFixture,
+ allow_dml: bool,
+ sql: str,
+ rejected_head: str | None,
+) -> None:
+ """
+ A client-side file-transfer command in an alert query is rejected whether
+ or not the database allows DML: it does file I/O on the host running the
+ query rather than reading or writing table data, so `allow_dml` does not
+ govern it. An ordinary read-only query is left alone either way.
+ """
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.database.backend = "snowflake"
+ report_schedule_mock.database.allow_dml = allow_dml
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ if rejected_head is None:
+ command._validate_rendered_sql(sql)
+ return
+
+ with pytest.raises(AlertQueryError, match=rejected_head):
+ command._validate_rendered_sql(sql)
diff --git a/tests/unit_tests/commands/sql_lab/test_estimate.py
b/tests/unit_tests/commands/sql_lab/test_estimate.py
index 2e2c1317096..b34cca55eef 100644
--- a/tests/unit_tests/commands/sql_lab/test_estimate.py
+++ b/tests/unit_tests/commands/sql_lab/test_estimate.py
@@ -216,6 +216,36 @@ def
test_apply_sql_security_allows_dml_when_enabled(mock_app: MagicMock) -> None
assert command._apply_sql_security("INSERT INTO t VALUES (1)")
[email protected]("allow_dml", [False, True])
[email protected](
+ "sql, expected_heads",
+ [
+ ("PUT file:///tmp/data.csv @my_stage", "PUT"),
+ ("GET @my_stage 'file:///tmp/'", "GET"),
+ ("REMOVE @my_stage/b; PUT file:///tmp/a @my_stage", "PUT, REMOVE"),
+ ],
+)
+@patch("superset.commands.sql_lab.estimate.app")
+def test_apply_sql_security_blocks_client_file_transfer(
+ mock_app: MagicMock, sql: str, expected_heads: str, allow_dml: bool
+) -> None:
+ """
+ The estimate path rejects client-side file-transfer statements regardless
+ of `allow_dml`, matching the execution path.
+ """
+ mock_app.config = {"DISALLOWED_SQL_FUNCTIONS": {},
"DISALLOWED_SQL_TABLES": {}}
+ from superset.exceptions import
SupersetDisallowedClientFileTransferException
+
+ command = _make_command_with_db(sql, allow_dml=allow_dml,
engine="snowflake")
+ with pytest.raises(SupersetDisallowedClientFileTransferException) as
excinfo:
+ command._apply_sql_security(sql)
+
+ assert excinfo.value.error.message == (
+ "SQL statement contains disallowed client-side "
+ f"file-transfer command(s): {expected_heads}"
+ )
+
+
@patch("superset.commands.sql_lab.estimate.is_feature_enabled",
return_value=False)
@patch("superset.commands.sql_lab.estimate.app")
def test_apply_sql_security_blocks_disallowed_table(
diff --git a/tests/unit_tests/sql/execution/test_executor.py
b/tests/unit_tests/sql/execution/test_executor.py
index ac171ff2580..7519cf6cd98 100644
--- a/tests/unit_tests/sql/execution/test_executor.py
+++ b/tests/unit_tests/sql/execution/test_executor.py
@@ -244,6 +244,57 @@ def test_execute_delete_without_permission(
assert "DML queries are not allowed" in result.error_message
+# =============================================================================
+# Client-side file-transfer statement tests
+# =============================================================================
+
+
[email protected]("allow_dml", [False, True])
[email protected](
+ "sql, expected_heads",
+ [
+ # Per-statement parsing of every form is covered in parse_tests; here
+ # one Command fallback, one structured node, one multi-statement.
+ ("PUT file:///tmp/data.csv @my_stage", "PUT"),
+ ("GET @my_stage 'file:///tmp/'", "GET"),
+ ("SELECT 1; PUT file:///tmp/data.csv @my_stage", "PUT"),
+ # Several heads render sorted and comma-separated, not as a raw set.
+ ("REMOVE @my_stage/b; PUT file:///tmp/a @my_stage", "PUT, REMOVE"),
+ ],
+)
+def test_check_security_rejects_client_file_transfer(
+ mocker: MockerFixture,
+ app_context: None,
+ sql: str,
+ expected_heads: str,
+ allow_dml: bool,
+) -> None:
+ """
+ Client-side file-transfer statements are rejected regardless of
+ `allow_dml`: they perform host file I/O, not DML.
+ """
+ from superset.exceptions import SupersetSecurityException
+ from superset.sql.execution.executor import SQLExecutor
+
+ mocker.patch.dict(
+ current_app.config,
+ {"DISALLOWED_SQL_FUNCTIONS": {}, "DISALLOWED_SQL_TABLES": {}},
+ )
+ database = Database(
+ id=3,
+ database_name="test_snowflake",
+ sqlalchemy_uri="snowflake://user:pw@account/db/schema",
+ allow_dml=allow_dml,
+ )
+
+ with pytest.raises(SupersetSecurityException) as excinfo:
+ SQLExecutor(database)._check_security(SQLScript(sql, "snowflake"))
+
+ assert excinfo.value.error.message == (
+ f"Disallowed client-side file-transfer command(s): {expected_heads}"
+ )
+
+
# =============================================================================
# Jinja2 Template Rendering Tests
# =============================================================================
diff --git a/tests/unit_tests/sql/parse_tests.py
b/tests/unit_tests/sql/parse_tests.py
index 616cb802905..ac1888dcdb1 100644
--- a/tests/unit_tests/sql/parse_tests.py
+++ b/tests/unit_tests/sql/parse_tests.py
@@ -2060,6 +2060,154 @@ def test_is_mutating_replace_function_is_read(engine:
str) -> None:
).is_mutating()
[email protected](
+ "sql, expected",
+ [
+ ("PUT file:///tmp/data.csv @my_stage", "PUT"),
+ ("GET @my_stage file:///tmp/", "GET"),
+ # A quoted local path parses to ``exp.Put``/``exp.Get`` rather than
+ # the opaque Command fallback, but is the same documented syntax.
+ ("PUT 'file:///tmp/data.csv' @my_stage", "PUT"),
+ ("GET @my_stage 'file:///tmp/'", "GET"),
+ ("REMOVE @my_stage/path", "REMOVE"),
+ # ``RM`` is a documented alias of ``REMOVE``.
+ ("RM @my_stage/path", "RM"),
+ # The head keyword is matched case-insensitively.
+ ("put file:///tmp/data.csv @my_stage", "PUT"),
+ # Ordinary analytics statements are not file-transfer commands.
+ ("SELECT 1", None),
+ ("INSERT INTO t VALUES (1)", None),
+ # ``LIST``/``LS`` only enumerate staged files (a read), so they are
+ # intentionally not treated as file-transfer commands here.
+ ("LIST @my_stage", None),
+ # A nested body runs for real but is kept as unparsed text, so the
+ # head match cannot see it and the raw body is scanned instead.
+ ("EXECUTE IMMEDIATE $$ REMOVE @my_stage/path $$", "REMOVE"),
+ ("EXECUTE IMMEDIATE 'RM @my_stage/path'", "RM"),
+ ("EXECUTE IMMEDIATE $$ PUT file:///tmp/data.csv @my_stage $$", "PUT"),
+ ("EXECUTE IMMEDIATE $$ GET @my_stage file:///tmp/ $$", "GET"),
+ # The body scan requires a stage/``file://`` reference after the head,
+ # so a body that merely names a column after one is not flagged.
+ ("EXECUTE IMMEDIATE $$ SELECT remove FROM t $$", None),
+ ("EXECUTE IMMEDIATE $$ SELECT put, rm FROM t $$", None),
+ # Commented-out code in a body never runs, so it is not a command.
+ ("EXECUTE IMMEDIATE $$ -- GET @my_stage file:///tmp/\nSELECT 1 $$",
None),
+ ("EXECUTE IMMEDIATE $$ /* PUT file:///tmp/a @s */ SELECT 1 $$", None),
+ # A `--` inside a literal opens no comment, so the text after it is
+ # still scanned.
+ ("EXECUTE IMMEDIATE $$ CALL p('a--b'); RM @my_stage/c $$", "RM"),
+ # A body runs dynamic SQL out of a literal, so a head inside one is
+ # matched: the literal is the statement the server executes.
+ ("CALL run('PUT file:///tmp/data.csv @my_stage')", "PUT"),
+ # A nested literal carries its own quotes doubled, so the scan has to
+ # look past a run of them rather than a single one.
+ ("EXECUTE IMMEDIATE 'PUT ''file:///tmp/data.csv'' @my_stage'", "PUT"),
+ ("CALL run('PUT ''file:///tmp/data.csv'' @my_stage')", "PUT"),
+ ("EXECUTE IMMEDIATE 'GET @my_stage ''file:///tmp/'''", "GET"),
+ # A `--` inside a dollar-quoted literal is data, not a comment, so the
+ # command after it is still scanned.
+ ("EXECUTE IMMEDIATE $$ SELECT $q$--$q$; RM @my_stage/c $$", "RM"),
+ # An unterminated block comment runs to the end of the body, so what
+ # follows it never executes.
+ ("EXECUTE IMMEDIATE $$ /* PUT file:///tmp/a @my_stage $$", None),
+ # A dollar-quoted argument that stops short of the end of the body is
+ # an ordinary literal, not the code wrapper, so it stays quoted: the
+ # `--` inside it opens no comment and the argument after it is scanned.
+ ("CALL p($$--$$, 'PUT file:///tmp/a @my_stage')", "PUT"),
+ ("CALL p($q$--$q$, 'GET @my_stage file:///tmp/a')", "GET"),
+ # An opener with no matching closer delimits nothing, so there is no
+ # wrapper to peel: the body is scanned as written and the command
+ # after the stray opener is still caught.
+ ("EXECUTE IMMEDIATE 'SELECT $q$ ; RM @my_stage/c'", "RM"),
+ # The body a literal carries is what the server runs, so a comment
+ # wedged inside it is stripped like any other: it cannot be used to
+ # split a head from its argument and slip the command past the scan.
+ ("EXECUTE IMMEDIATE 'PUT/*x*/file:///tmp/a @my_stage'", "PUT"),
+ ("EXECUTE IMMEDIATE 'PUT -- x\nfile:///tmp/a @my_stage'", "PUT"),
+ ("CALL p('REMOVE/*x*/@my_stage/b')", "REMOVE"),
+ # Stripping inside a literal stops at its closing delimiter, so a `--`
+ # in one still cannot comment out the statements that follow it.
+ ("EXECUTE IMMEDIATE $$ CALL p('--'); RM @my_stage/c $$", "RM"),
+ # A backslash escapes the quote on this dialect, so the literal does
+ # not end at the quote it precedes. Reading that quote as the closing
+ # one would leave the rest of the body looking like code, letting the
+ # `--` blank out the command that follows it.
+ ("CALL p('it\\'s -- x', 'PUT file:///tmp/a @my_stage')", "PUT"),
+ ('CALL p("it\\"s -- x", \'GET @my_stage file:///tmp/a\')', "GET"),
+ # A head that really is commented out inside the body still does not
+ # run, so it is still not reported.
+ ("EXECUTE IMMEDIATE 'SELECT 1 -- PUT file:///tmp/a @my_stage'", None),
+ ("CALL some_procedure()", None),
+ ],
+)
+def test_get_client_file_transfer_command(sql: str, expected: str | None) ->
None:
+ """
+ `get_client_file_transfer_command` returns the command head for Snowflake
+ client-side file-transfer statements and ``None`` for anything else.
+ """
+ assert SQLStatement(sql, "snowflake").get_client_file_transfer_command()
== expected
+
+
[email protected](
+ "sql, expected",
+ [
+ ("PUT file:///tmp/data.csv @my_stage", ["PUT"]),
+ ("SELECT 1; GET @my_stage file:///tmp/", ["GET"]),
+ ("SELECT 1; PUT 'file:///tmp/data.csv' @my_stage", ["PUT"]),
+ # Heads are deduplicated and returned in sorted order, so the error
+ # messages built from them read the same on every run. Written here in
+ # the reverse of the order the statements appear in.
+ ("REMOVE @s/b; PUT file:///a @s", ["PUT", "REMOVE"]),
+ ("PUT file:///a @s; PUT file:///b @s", ["PUT"]),
+ ("SELECT 1; EXECUTE IMMEDIATE $$ REMOVE @s/b $$", ["REMOVE"]),
+ ("SELECT 1", []),
+ ],
+)
+def test_get_client_file_transfer_commands_script(
+ sql: str, expected: list[str]
+) -> None:
+ """
+ `SQLScript.get_client_file_transfer_commands` collects every file-transfer
+ command head across the statements in a multi-statement script, sorted and
+ deduplicated.
+ """
+ assert SQLScript(sql, "snowflake").get_client_file_transfer_commands() ==
expected
+
+
+def test_strip_comments_bounds_literal_nesting() -> None:
+ """
+ `_strip_comments` stops descending into nested literals past a fixed depth,
+ so a body whose nesting a user controls cannot exhaust the stack. Past the
+ bound the text is left as found rather than dropped, so it stays visible to
+ the gates that scan it.
+ """
+ statement = SQLStatement("SELECT 1", "postgresql")
+ depth = SQLStatement._MAX_LITERAL_NESTING + 1
+ # Each `$t<n>$ ... $t<n>$` region is one level of literal nesting.
+ nested = "/* c */"
+ for level in range(depth):
+ nested = f"$t{level}${nested}$t{level}$"
+
+ stripped = statement._strip_comments(nested)
+
+ # The whole structure survives, and the comment below the bound does not.
+ assert stripped.startswith(f"$t{depth - 1}$")
+ assert "/* c */" in stripped
+ # The same comment one level above the bound is stripped.
+ assert "/* c */" not in statement._strip_comments("$t0$/* c */$t0$")
+
+
+def test_split_literal_reports_unterminated_dollar_quote() -> None:
+ """
+ `_split_literal` reports an empty closing delimiter when a dollar-quoted
+ literal never closes, so a caller can tell an unterminated region apart
+ from one that closes normally rather than treating the two the same.
+ """
+ opening, interior, closing = SQLStatement._split_literal("$t$--")
+
+ assert (opening, interior, closing) == ("$t$", "--", "")
+
+
@pytest.mark.parametrize(
"sql, expected",
[
@@ -5800,6 +5948,18 @@ def test_get_disallowed_tables_search_path_change(
("SELECT set_config('statement_timeout', '0', true)", False),
# An unrelated (non-`set_config`) function call is not a change either.
("SELECT my_custom_func(1)", False),
+ # The nested-body text is scanned with comments removed, so a rebind
+ # that is only commented out is not a change, while one carried in a
+ # string literal (the body's dynamic SQL) still is.
+ ("DO $$ BEGIN -- SET search_path TO evil\nPERFORM 1; END $$", False),
+ ("DO $$ BEGIN /* SET search_path TO evil */ PERFORM 1; END $$", False),
+ ("EXECUTE IMMEDIATE 'SET search_path TO evil'", True),
+ # A `--` inside a dollar-quoted literal is data, not a comment, so
+ # removing comments must not swallow the rebind that follows it.
+ (
+ "DO $$ BEGIN PERFORM $q$--$q$; EXECUTE 'SET search_path TO evil';
END $$",
+ True,
+ ),
("SELECT 1", False),
],
)
@@ -5876,6 +6036,34 @@ def test_changes_search_path(sql: str, expected: bool)
-> None:
("EXPLAIN SET SCHEMA 'tenant_b'", "postgresql", False),
("EXPLAIN VERBOSE SELECT * FROM orders", "postgresql", False),
("EXPLAIN (COSTS) SELECT * FROM orders", "postgresql", False),
+ # The nested-body text is scanned with comments removed, so a rebind
+ # that is only commented out is not a change, while one carried in a
+ # string literal (the body's dynamic SQL) still is.
+ ("DO $$ BEGIN -- SET SCHEMA 'evil'\nPERFORM 1; END $$", "postgresql",
False),
+ ("EXECUTE IMMEDIATE 'SET SCHEMA ''evil'''", "postgresql", True),
+ # MySQL-family engines only open a comment on `--` when whitespace
+ # follows, so `1--2` is arithmetic there and the rest of the body still
+ # executes; a spaced `-- ` is a comment on every dialect.
+ ("CALL p(1--2, 'SET SCHEMA evil')", "mysql", True),
+ ("CALL p(1, 'x') -- SET SCHEMA evil", "mysql", False),
+ ("CALL p(1--2, 'SET SCHEMA evil')", "postgresql", False),
+ # SingleStore speaks the MySQL wire protocol and takes the same rule,
+ # so it must not lose the rest of the body to an unspaced `--`.
+ ("CALL p(1--2, 'SET SCHEMA evil')", "singlestoredb", True),
+ ("CALL p(1, 'x') -- SET SCHEMA evil", "singlestoredb", False),
+ # Whether a backslash escapes is read off the dialect too. Where it
+ # does, the literal does not end at the quote the backslash precedes,
+ # so the `--` after it is still inside that literal and cannot comment
+ # out the rebind carried in the next argument.
+ ("CALL p('it\\'s -- x', 'SET SCHEMA evil')", "snowflake", True),
+ ("CALL p('it\\'s -- x', 'SET SCHEMA evil')", "mysql", True),
+ # PostgreSQL has no backslash escape, so the literal really does end
+ # there and the `--` that follows opens a comment.
+ ("CALL p('it\\'s -- x', 'SET SCHEMA evil')", "postgresql", False),
+ # The family is identified by subclassing `MySQL`, so an engine that
+ # joins it without being named anywhere here takes the rule too.
+ ("CALL p(1--2, 'SET SCHEMA evil')", "pinot", True),
+ ("CALL p(1, 'x') -- SET SCHEMA evil", "pinot", False),
# Engines without a sqlglot AST (e.g. Kusto KQL) do not rebind schema
# resolution through these forms.
("print x = 1", "kustokql", False),
@@ -6437,6 +6625,25 @@ def test_backtick_invalid_sql_still_fails() -> None:
SQLScript(sql, "base")
+def
test_base_sql_statement_get_client_file_transfer_command_defaults_to_none() ->
None:
+ """
+ BaseSQLStatement.get_client_file_transfer_command defaults to None, so a
+ dialect without such commands inherits the safe answer rather than having
+ to override it.
+ """
+ assert BaseSQLStatement.get_client_file_transfer_command(object()) is None
# type: ignore[arg-type] # noqa: E501
+
+
+def test_kusto_kql_has_no_client_file_transfer_command() -> None:
+ """
+ Kusto KQL has no client-side file-transfer commands.
+ """
+ assert (
+ KustoKQLStatement(".show tables",
"kustokql").get_client_file_transfer_command()
+ is None
+ )
+
+
def test_base_sql_statement_is_destructive_raises_not_implemented() -> None:
"""
BaseSQLStatement.is_destructive is abstract; both concrete subclasses
diff --git a/tests/unit_tests/sql_lab_test.py b/tests/unit_tests/sql_lab_test.py
index 13e53b19103..f76ff4c1af8 100644
--- a/tests/unit_tests/sql_lab_test.py
+++ b/tests/unit_tests/sql_lab_test.py
@@ -301,6 +301,45 @@ def
test_execute_sql_statement_within_payload_limit(mocker: MockerFixture, app)
)
[email protected]("allow_dml", [False, True])
+def test_execute_sql_statements_rejects_client_file_transfer(
+ mocker: MockerFixture, app: SupersetApp, allow_dml: bool
+) -> None:
+ """
+ `execute_sql_statements` rejects client-side file-transfer statements
+ regardless of `allow_dml`: they perform host file I/O, not DML.
+ """
+ from superset.exceptions import
SupersetDisallowedClientFileTransferException
+
+ query = mocker.MagicMock()
+ query.limit = 1
+ query.status = "RUNNING"
+ query.select_as_cta = False
+ query.database.allow_dml = allow_dml
+ query.database.allow_run_async = False
+ query.database.db_engine_spec.engine = "snowflake"
+
+ mocker.patch("superset.sql_lab.get_query", return_value=query)
+ mocker.patch("superset.sql_lab.db.session.refresh", return_value=None)
+
+ with pytest.raises(SupersetDisallowedClientFileTransferException) as
excinfo:
+ execute_sql_statements(
+ query_id=1,
+ rendered_query="REMOVE @my_stage/b; PUT file:///tmp/data.csv
@my_stage",
+ return_results=True,
+ store_results=False,
+ start_time=None,
+ expand_data=False,
+ log_params={},
+ )
+
+ # Sorted and comma-separated, not raw set interpolation.
+ assert excinfo.value.error.message == (
+ "SQL statement contains disallowed client-side "
+ "file-transfer command(s): PUT, REMOVE"
+ )
+
+
def test_execute_sql_statements_mutates_before_split_by_default(
mocker: MockerFixture, app: SupersetApp
) -> None: