feat(sql): schema-qualified table denylist + information_schema/lo_* defaults (#41120)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Shaitan
2026-07-01 16:57:45 +01:00
committed by GitHub
parent 3651020014
commit 2da2db6c7c
13 changed files with 1143 additions and 139 deletions

View File

@@ -1374,8 +1374,10 @@ def test_execute_uses_default_catalog_and_schema(
get_default_catalog_mock = mocker.patch.object(
database, "get_default_catalog", return_value="main"
)
# Schema is resolved through the query-aware ``get_default_schema_for_query``
# (so per-query engine gates run), not the static ``get_default_schema``.
get_default_schema_mock = mocker.patch.object(
database, "get_default_schema", return_value="public"
database, "get_default_schema_for_query", return_value="public"
)
mocker.patch.dict(
current_app.config,
@@ -1395,6 +1397,88 @@ def test_execute_uses_default_catalog_and_schema(
get_default_schema_mock.assert_called()
def test_resolve_query_schema_uses_query_aware_resolution(
mocker: MockerFixture, database: Database, app_context: None
) -> None:
"""``_resolve_query_schema`` resolves through the query-aware
``get_default_schema_for_query`` (which runs per-query engine security gates),
handing it a transient probe Query that carries the request's SQL, schema,
catalog and template params."""
from superset.models.sql_lab import Query
from superset.sql.execution.executor import SQLExecutor
resolve_mock = mocker.patch.object(
database, "get_default_schema_for_query", return_value="resolved_schema"
)
executor = SQLExecutor(database)
options = QueryOptions(schema="explicit", template_params={"p": 1})
result = executor._resolve_query_schema("SELECT 1", options, "cat")
assert result == "resolved_schema"
probe, template_params = resolve_mock.call_args.args
assert isinstance(probe, Query)
assert probe.sql == "SELECT 1"
assert probe.schema == "explicit"
assert probe.catalog == "cat"
assert template_params == {"p": 1}
def test_resolve_query_schema_omits_blank_schema(
mocker: MockerFixture, database: Database, app_context: None
) -> None:
"""An unset request schema reaches the probe as ``None`` so the engine spec
resolves the runtime default instead of matching on an empty string."""
resolve_mock = mocker.patch.object(
database, "get_default_schema_for_query", return_value="public"
)
from superset.sql.execution.executor import SQLExecutor
executor = SQLExecutor(database)
result = executor._resolve_query_schema("SELECT 1", QueryOptions(), None)
assert result == "public"
probe = resolve_mock.call_args.args[0]
assert probe.schema is None
assert probe.catalog is None
def test_prepare_sql_runs_schema_gate_with_explicit_schema(
mocker: MockerFixture, database: Database, app_context: None
) -> None:
"""The per-query schema gate must run even when an explicit schema is
supplied, so an explicit-schema request cannot smuggle a ``SET search_path``
past the gate the resolver enforces (parity with the estimate path, which
resolves unconditionally)."""
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.sql.execution.executor import SQLExecutor
gate = mocker.patch.object(
database,
"get_default_schema_for_query",
side_effect=SupersetSecurityException(
SupersetError(
message="blocked",
error_type=SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR,
level=ErrorLevel.ERROR,
)
),
)
executor = SQLExecutor(database)
with pytest.raises(SupersetSecurityException):
executor._prepare_sql(
"SET search_path = secret; SELECT 1",
QueryOptions(schema="explicit"),
)
gate.assert_called_once()
# =============================================================================
# Async Query Status and Result Tests
# =============================================================================

View File

@@ -28,6 +28,7 @@ from superset.exceptions import QueryClauseValidationException, SupersetParseErr
from superset.jinja_context import JinjaTemplateProcessor
from superset.sql.parse import (
_check_script_length,
BaseSQLStatement,
CTASMethod,
extract_tables_from_statement,
JinjaSQLResult,
@@ -1458,7 +1459,12 @@ def test_is_mutating_anonymous_block(sql: str, expected: bool) -> None:
("SELECT lo_import('/etc/passwd')", True),
("SELECT lo_put(12345, 0, decode('00', 'hex'))", True),
("SELECT lo_create(0)", True),
# lo_creat is the legacy large-object creator (distinct from lo_create).
("SELECT lo_creat(-1)", True),
("SELECT lowrite(12345, decode('00', 'hex'))", True),
# lo_truncate/lo_truncate64 shrink an existing large object: a write.
("SELECT lo_truncate(12345, 0)", True),
("SELECT lo_truncate64(12345, 0)", True),
# lo_unlink deletes a large object outright.
("SELECT lo_unlink(12345)", True),
# PostgreSQL sequence mutators. setval()/nextval() look like reads but
@@ -3510,6 +3516,441 @@ def test_check_tables_present(sql: str, engine: str, expected: bool) -> None:
assert SQLScript(sql, engine).check_tables_present(tables) == expected
@pytest.mark.parametrize(
"engine, sql, denylist, expected",
[
# Postgres: schema-qualified denylist entry matches schema-qualified
# reference.
(
"postgresql",
"SELECT * FROM information_schema.tables",
{"information_schema.tables"},
True,
),
# ... and is case-insensitive.
(
"postgresql",
"SELECT * FROM INFORMATION_SCHEMA.TABLES",
{"information_schema.tables"},
True,
),
# Schema-qualified denylist entry does NOT match a bare-name table
# of the same name in another schema. A user table named `tables`
# remains queryable.
(
"postgresql",
"SELECT * FROM public.tables",
{"information_schema.tables"},
False,
),
(
"postgresql",
"SELECT * FROM tables",
{"information_schema.tables"},
False,
),
# Bare-name denylist entry still matches by table name only
# (existing behavior, schema-agnostic).
(
"postgresql",
"SELECT * FROM pg_stat_activity",
{"pg_stat_activity"},
True,
),
(
"postgresql",
"SELECT * FROM pg_catalog.pg_stat_activity",
{"pg_stat_activity"},
True,
),
# Mixed entries: one schema-qualified, one bare. Match either.
(
"postgresql",
"SELECT * FROM information_schema.columns",
{"information_schema.tables", "information_schema.columns"},
True,
),
(
"postgresql",
"SELECT * FROM pg_roles",
{"information_schema.tables", "pg_roles"},
True,
),
# Negative control.
(
"postgresql",
"SELECT * FROM my_table",
{"information_schema.tables", "pg_roles"},
False,
),
# MySQL: the shipped DISALLOWED_SQL_TABLES['mysql'] entries are all
# schema-qualified (`mysql.user`, `performance_schema.threads`,
# `performance_schema.processlist`). Without schema-aware matching
# the entries are dead config. These cases pin the fix.
(
"mysql",
"SELECT user, host, authentication_string FROM mysql.user",
{"mysql.user"},
True,
),
(
"mysql",
"SELECT * FROM performance_schema.threads",
{"performance_schema.threads"},
True,
),
(
"mysql",
"SELECT * FROM performance_schema.processlist",
{"performance_schema.processlist"},
True,
),
# MySQL must NOT block a user-authored table that shares the leaf
# name with the system view.
(
"mysql",
"SELECT * FROM mydb.user",
{"mysql.user"},
False,
),
# MSSQL: same shape, `sys.*` entries are schema-qualified.
(
"mssql",
"SELECT name, password_hash FROM sys.sql_logins",
{"sys.sql_logins"},
True,
),
(
"mssql",
"SELECT name, sid FROM sys.server_principals",
{"sys.server_principals"},
True,
),
(
"mssql",
"SELECT * FROM sys.configurations",
{"sys.configurations"},
True,
),
# MSSQL must NOT block a user-authored table sharing the leaf name.
(
"mssql",
"SELECT * FROM mydb.sql_logins",
{"sys.sql_logins"},
False,
),
# Three-part (catalog.schema.table) denylist entries match the
# fully-qualified reference, the multi-dot form is indexed rather than
# silently dead.
(
"trino",
"SELECT * FROM cat.sys.sql_logins",
{"cat.sys.sql_logins"},
True,
),
# ... and a different catalog must NOT match.
(
"trino",
"SELECT * FROM other.sys.sql_logins",
{"cat.sys.sql_logins"},
False,
),
],
)
def test_check_tables_present_schema_qualified(
engine: str, sql: str, denylist: set[str], expected: bool
) -> None:
"""
`check_tables_present` must distinguish schema-qualified denylist
entries (e.g. `information_schema.tables`, `mysql.user`,
`sys.sql_logins`) from bare-name entries (e.g. `pg_stat_activity`).
Schema-qualified entries only match schema-qualified references in
the SQL; bare entries match the table name regardless of schema.
Covers Postgres, MySQL, and MSSQL dialects so the shipped
DISALLOWED_SQL_TABLES entries for each remain effective.
"""
assert SQLScript(sql, engine).check_tables_present(denylist) == expected
@pytest.mark.parametrize(
"engine, sql, denylist, expected",
[
# A schema-qualified match is reported in its original denylist form,
# not collapsed to the bare leaf name and not the whole denylist.
(
"postgresql",
"SELECT * FROM information_schema.tables",
{"information_schema.tables", "information_schema.columns", "pg_roles"},
{"information_schema.tables"},
),
# Bare-name match is reported as-is.
(
"postgresql",
"SELECT * FROM pg_catalog.pg_stat_activity",
{"pg_stat_activity", "pg_roles"},
{"pg_stat_activity"},
),
# Multiple references across statements union their matches.
(
"postgresql",
"SELECT * FROM information_schema.tables; SELECT * FROM pg_roles",
{"information_schema.tables", "pg_roles", "pg_settings"},
{"information_schema.tables", "pg_roles"},
),
# No match returns an empty set.
(
"postgresql",
"SELECT * FROM my_table",
{"information_schema.tables", "pg_roles"},
set(),
),
# A three-part (catalog.schema.table) denylist entry matches a
# fully-qualified reference, reported in its original form.
(
"trino",
"SELECT * FROM cat.sys.sql_logins",
{"cat.sys.sql_logins"},
{"cat.sys.sql_logins"},
),
# ... but only when the catalog lines up: a different catalog does not
# match the three-part entry.
(
"trino",
"SELECT * FROM other.sys.sql_logins",
{"cat.sys.sql_logins"},
set(),
),
],
)
def test_get_disallowed_tables(
engine: str, sql: str, denylist: set[str], expected: set[str]
) -> None:
"""
`get_disallowed_tables` returns exactly the denylist entries referenced,
in their original (possibly schema-qualified) form, so callers can report
precisely which tables were hit instead of echoing the whole denylist.
"""
assert SQLScript(sql, engine).get_disallowed_tables(denylist) == expected
@pytest.mark.parametrize(
"sql, default_schema, denylist, expected",
[
# Unqualified reference resolves to the default schema, so it matches
# a schema-qualified denylist entry when the schemas line up (e.g. a
# connection whose search_path is `information_schema`).
(
"SELECT * FROM tables",
"information_schema",
{"information_schema.tables"},
{"information_schema.tables"},
),
# ... case-insensitively.
(
"SELECT * FROM tables",
"INFORMATION_SCHEMA",
{"information_schema.tables"},
{"information_schema.tables"},
),
# The same unqualified name under a user schema must NOT match: a user
# table named `tables` stays queryable.
(
"SELECT * FROM tables",
"public",
{"information_schema.tables"},
set(),
),
# An explicit schema on the reference wins over the default schema.
(
"SELECT * FROM public.tables",
"information_schema",
{"information_schema.tables"},
set(),
),
# Without a default schema, behavior is unchanged: unqualified
# references never match schema-qualified entries.
(
"SELECT * FROM tables",
None,
{"information_schema.tables"},
set(),
),
# Bare-name denylist entries are schema-agnostic and unaffected by the
# default schema.
(
"SELECT * FROM pg_stat_activity",
"information_schema",
{"pg_stat_activity"},
{"pg_stat_activity"},
),
# The default schema is forwarded to every statement in a script, so an
# unqualified reference in a later statement is resolved too.
(
"SELECT * FROM my_table; SELECT * FROM tables",
"information_schema",
{"information_schema.tables"},
{"information_schema.tables"},
),
],
)
def test_get_disallowed_tables_default_schema(
sql: str,
default_schema: str | None,
denylist: set[str],
expected: set[str],
) -> None:
"""
`get_disallowed_tables` resolves an unqualified reference against the
supplied default schema, so a denylisted system view (e.g.
`information_schema.tables`) is still caught when reached without an
explicit schema under that search_path, without blocking a same-named
user table under a different schema.
"""
assert (
SQLScript(sql, "postgresql").get_disallowed_tables(denylist, default_schema)
== expected
)
@pytest.mark.parametrize(
"sql, default_schema, denylist, expected",
[
# `SET search_path` rebinds where an unqualified reference resolves, so
# the static default schema can no longer be trusted. A qualified
# denylist entry must still match the later unqualified reference,
# otherwise the block is trivially bypassable.
(
"SET search_path = information_schema; SELECT * FROM tables",
"public",
{"information_schema.tables"},
{"information_schema.tables"},
),
# `SET search_path TO "$user", ...` falls back to an exp.Command (it is
# not a structured exp.Set), exercising the same conservative matching
# via the command-name detection branch.
(
'SET search_path TO "$user", information_schema; SELECT * FROM tables',
"public",
{"information_schema.tables"},
{"information_schema.tables"},
),
# `set_config('search_path', ...)` rebinds the search path through a
# function call and must trigger the same conservative matching.
(
"SELECT set_config('search_path', 'information_schema', true);"
" SELECT * FROM tables",
"public",
{"information_schema.tables"},
{"information_schema.tables"},
),
# The search-path change only affects later statements: a statement that
# runs before it keeps resolving against the original default schema, so
# its unqualified reference must NOT be widened.
(
"SELECT * FROM tables; SET search_path = information_schema",
"public",
{"information_schema.tables"},
set(),
),
# An explicitly qualified reference is unambiguous and must NOT be
# widened to match a different schema's denylist entry.
(
"SET search_path = information_schema; SELECT * FROM public.tables",
"public",
{"information_schema.tables"},
set(),
),
# Without a search_path change, matching is unchanged: an unqualified
# reference under a user schema does not match the qualified entry.
(
"SELECT * FROM tables",
"public",
{"information_schema.tables"},
set(),
),
],
)
def test_get_disallowed_tables_search_path_change(
sql: str,
default_schema: str | None,
denylist: set[str],
expected: set[str],
) -> None:
"""
A `SET search_path` in the script makes unqualified references resolve to a
schema other than the caller's default, so `get_disallowed_tables` matches
them against schema-qualified entries too, closing a denylist bypass.
"""
assert (
SQLScript(sql, "postgresql").get_disallowed_tables(denylist, default_schema)
== expected
)
@pytest.mark.parametrize(
"sql, expected",
[
# Structured `SET search_path` (exp.Set), surfaced via get_settings().
("SET search_path = information_schema", True),
# Exotic form that falls back to exp.Command; the leading setting name
# is `search_path`.
('SET search_path TO "$user", public', True),
# `SET SESSION ...` can also fall back to exp.Command; the optional
# SESSION/LOCAL qualifier is skipped before matching the setting name.
("SET SESSION search_path FROM CURRENT", True),
# A quoted identifier is equivalent to the unquoted form in Postgres,
# so it must be recognized too (both the exp.Set and exp.Command forms).
('SET "search_path" = information_schema', True),
('SET "search_path" TO "$user", public', True),
# A `SET` whose value merely contains the substring `search_path` must
# not be misclassified (the setting being changed is `ROLE`).
("SET ROLE app_search_path_user", False),
# `set_config('search_path', ...)` rebinds the path via a function call.
("SELECT set_config('search_path', 'information_schema', true)", True),
# A different setting changed through `set_config` is not a 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),
("SELECT 1", False),
],
)
def test_changes_search_path(sql: str, expected: bool) -> None:
"""
`changes_search_path` detects search-path rebinds (via `SET` or
`set_config`) without misclassifying unrelated `SET` statements.
"""
assert SQLStatement(sql, "postgresql").changes_search_path() == expected
@pytest.mark.parametrize(
"sql, denylist, expected",
[
("SELECT * FROM pg_stat_activity", {"pg_stat_activity"}, True),
("SELECT * FROM my_table", {"pg_stat_activity"}, False),
],
)
def test_statement_check_tables_present(
sql: str, denylist: set[str], expected: bool
) -> None:
"""
`SQLStatement.check_tables_present` is the per-statement entry point that
`SQLScript` no longer routes through (it calls `get_disallowed_tables`
directly), so exercise it on its own to keep the override covered.
"""
assert SQLStatement(sql, "postgresql").check_tables_present(denylist) == expected
def test_kustokql_statement_check_tables_present() -> None:
"""
`KustoKQLStatement.check_tables_present` is unsupported and always reports
False; exercise it directly so the override stays covered.
"""
statement = KustoKQLStatement("foo | take 100", "kustokql")
assert statement.check_tables_present({"foo"}) is False
@pytest.mark.parametrize(
"kql, expected",
[
@@ -3701,6 +4142,17 @@ def test_backtick_invalid_sql_still_fails() -> None:
SQLScript(sql, "base")
def test_base_sql_statement_is_destructive_raises_not_implemented() -> None:
"""
BaseSQLStatement.is_destructive is abstract; both concrete subclasses
(SQLStatement and KustoKQLStatement) override it, so calling the base
implementation directly must raise. This exercises the abstract stub
so it stays exercised under coverage.
"""
with pytest.raises(NotImplementedError):
BaseSQLStatement.is_destructive(object()) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# SQL_MAX_PARSE_LENGTH gate
# ---------------------------------------------------------------------------