Compare commits

...
Author SHA1 Message Date
rusackasandClaude Fable 5.1 39b8693822 refactor(security): share the row-expanding result-type check between chartless guards
The Drill By and native-filter guards carried the same any(...) expression
over _ROW_EXPANDING_RESULT_TYPES; fold it into _any_row_expanding_result_type
so the set and effective-type logic have one call site to keep in sync.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ybdwS1GbAy3Pkt6YGKCQp
2026-09-05 07:18:39 -07:00
rusackasandClaude Fable 5.1 c89e5edefc refactor(security): table-drive the guest payload comparators in query_context_modified
Rebasing onto master pulled in the JSONDecodeError branch, which pushed
query_context_modified past ruff's C901 threshold. Collapse the six
comparator/warn/return blocks into one loop over (comparator, reason) pairs;
behavior and log messages are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ybdwS1GbAy3Pkt6YGKCQp
2026-09-05 07:18:39 -07:00
rusackasandClaude Opus 4.8 05dc360b28 fix(security): reject Drill By row-expanding result types on chartless requests
Drill By (slice_id sentinel 0 plus a source chart_id) is only supposed to
read the chart's configured drillable dimensions, not the whole datasource.
The samples/drill_detail preparers ignore groupby entirely and return every
column, bypassing that allowlist. Drill to Detail (no slice_id/chart_id at
all) is unaffected since it's already meant to expose a full dataset
attached to the dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 07:18:38 -07:00
rusackasandClaude Opus 4.8 32e3b5aba6 fix(security): reject row-expanding result types on native-filter requests
The samples/drill_detail preparers replace a query's column list with every
column on the datasource, bypassing the native-filter target-column
allowlist entirely. Reject those result types outright on the chartless
native-filter path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 07:18:38 -07:00
Superset DevandClaude Sonnet 5 bac3e50a11 fix(embedded): compare each query's result type against its own stored query, not the flattened set
_result_type_modified previously unioned every stored query's result_type
into one set and checked requested result types against that set, so a
result type stored on one query (e.g. samples) could be requested for a
different query in the same query context. Compare each requested query's
effective result type (its own result_type, falling back to the query
context's top-level result_type - mirroring how the value is resolved at
render time) against the corresponding stored query at the same position
instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:18:37 -07:00
Superset Dev b066cdb894 fix(embedded): stop rendering dashboard title/description before guest auth
The Referer and Sec-Fetch-Dest checks on the pre-token embed page are
browser-cooperation only; a non-browser client can forge or omit both
headers, so anything the page renders is effectively reachable by
knowing an embed UUID. Stop passing the dashboard title and description
into the template at this stage. The embedded SPA already fetches
dashboard metadata through the guest-token-authenticated API once it
holds a token, so the pre-auth page falls back to a generic title.
2026-09-05 07:17:20 -07:00
Superset Dev e08790c951 fix(guest-tokens): reject result types that expand a chart to raw rows
query_context_modified() compared columns/metrics/order-by against the
stored chart but never compared result_type, and the samples/drill_detail
preparers in query_actions.py rewrite an accepted query to every column
on the datasource (dropping metrics/post_processing) after that check has
already passed. Add _result_type_modified(), checking the request's
top-level and per-query result_type (samples or drill_detail) against
the stored chart's own saved query context; unless the chart itself uses
one of those result types, requesting it now fails the guest tamper
check.
2026-09-05 07:17:19 -07:00
Superset Dev f452ecc498 fix(guest-tokens): reject annotation layers not on the stored chart
query_context_modified() compared columns/metrics/series-limit/order-by
against a chart's stored query context but never inspected
annotation_layers, which is accepted on any query object and resolved by
AnnotationLayerDAO.find_by_ids with no per-object restriction. Add
_annotation_layers_modified(), matching the existing subset-comparator
pattern: the union of annotation layers in form_data and every query
object must already appear (by sourceType/value identity) on the stored
chart's params or saved query_context. Replaying a chart's own layers
still passes; referencing any other layer now fails the guest tamper
check.
2026-09-05 07:17:19 -07:00
4 changed files with 513 additions and 67 deletions
+5 -3
View File
@@ -59,7 +59,6 @@ class EmbeddedView(BaseSupersetView):
abort(404)
assert embedded is not None
dashboard = embedded.dashboard
# validate request referrer in allowed domains
is_referrer_allowed = not embedded.allowed_domains
@@ -109,11 +108,14 @@ class EmbeddedView(BaseSupersetView):
},
}
# This page renders before any guest token has been presented, and the
# Referer / Sec-Fetch-Dest checks above are browser cooperation only --
# a non-browser client can forge or omit both. Serve a neutral shell:
# no dashboard title or description here; the embedded SPA fetches
# dashboard metadata through the guest-token-authenticated API.
return self.render_template(
"superset/spa.html",
entry="embedded",
title=dashboard.dashboard_title,
dashboard_description=dashboard.description,
bootstrap_data=json.dumps(
bootstrap_data, default=json.pessimistic_json_iso_dttm_ser
),
+267 -64
View File
@@ -74,6 +74,7 @@ from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.orm.query import Query as SqlaQuery
from sqlalchemy.sql import exists
from superset.common.chart_data import ChartDataResultType
from superset.constants import RouteMethod
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import (
@@ -871,28 +872,67 @@ def _native_filter_query_modified(
return False
def _any_row_expanding_result_type(query_context: "QueryContext") -> bool:
"""
Whether any query in the context asks for a result type that has the
server return every column on the datasource
(see ``_ROW_EXPANDING_RESULT_TYPES``).
"""
return any(
_effective_result_type(
getattr(query, "result_type", None), query_context.result_type
)
in _ROW_EXPANDING_RESULT_TYPES
for query in query_context.queries
)
def _drill_by_row_expanding_result_type(query_context: "QueryContext") -> bool:
"""
Whether a chartless Drill By request (``slice_id`` sentinel ``0`` plus a
source ``chart_id``) asks for a result type that expands a query to every
column on the datasource.
``raise_for_access`` grants Drill By only after confirming the requested
``groupby`` dimensions are configured as drillable columns on the source
chart's datasource (see ``has_drill_access``); a guest's entitlement there
is that specific dimension allowlist, not the whole table. The
samples/drill_detail preparers ignore ``groupby`` entirely and return
every column, which would bypass that allowlist - unlike Drill to Detail
(no ``slice_id``/``chart_id`` at all), which is already meant to expose a
full dataset already attached to the dashboard.
"""
form_data = query_context.form_data or {}
if not (form_data.get("slice_id") == 0 and form_data.get("chart_id")):
return False
return _any_row_expanding_result_type(query_context)
def _native_filter_request_modified(query_context: "QueryContext") -> bool:
"""
Validate a chartless data request that targets a native filter.
Only requests identified as native-filter lookups (by the ``NATIVE_FILTER``
type marker or a ``native_filter_id``) are constrained; other chartless
type marker or a ``native_filter_id``) are constrained here; other chartless
paths (drill-to-detail, drill-by, samples) carry neither and are validated by
the datasource-access checks in raise_for_access, so they are not treated as
modified here.
modified here beyond the Drill By result-type guard in
``_drill_by_row_expanding_result_type``.
A native filter may only read the column(s) it targets on the dashboard it
belongs to. The request is treated as modified (and therefore rejected for
guest users) when it cannot be tied to a native filter on the requesting
dashboard, or when any value-returning term (column, group-by, series
column, metric, series-limit metric, or order-by) references something
other than a target column, a simple
aggregate over a target column, or the filter's configured sort metric.
Free-form SQL terms and saved metrics other than the configured sort metric
are rejected. Row-restricting clauses (``filter``/``extras``) are not
constrained here: cross-filters legitimately reference other columns and
they do not return column values; that blind-inference surface is a separate
concern shared with the chart path.
dashboard, when it asks for a result type that expands the query to raw
datasource rows (see ``_ROW_EXPANDING_RESULT_TYPES``), or when any
value-returning term (column, group-by, series column, metric,
series-limit metric, or order-by) references something other than a
target column, a simple aggregate over a target column, or the filter's
configured sort metric. Free-form SQL terms and saved metrics other than
the configured sort metric are rejected. Row-restricting clauses
(``filter``/``extras``) are not constrained here: cross-filters
legitimately reference other columns and they do not return column
values; that blind-inference surface is a separate concern shared with
the chart path.
"""
form_data = query_context.form_data or {}
if not (
@@ -907,6 +947,13 @@ def _native_filter_request_modified(query_context: "QueryContext") -> bool:
# intentionally deny every value-returning term below.
allowed_columns, allowed_metrics = targets
# The samples/drill_detail preparers replace a query's columns with every
# column on the datasource - bypassing the target-column allowlist below
# entirely - so reject those result types outright; a native filter never
# legitimately needs them.
if _any_row_expanding_result_type(query_context):
return True
return any(
_native_filter_query_modified(query, allowed_columns, allowed_metrics)
for query in query_context.queries
@@ -1615,6 +1662,146 @@ def _columns_metrics_modified(
return False
def _annotation_layer_identity(layer: Any) -> Optional[tuple[str, str]]:
"""
Identity of an annotation layer for tamper comparison: the source type and
the underlying source it reads (a native annotation-layer id or a chart
id). Cosmetic keys (``name``, styling, overrides) are not part of the
identity. Returns ``None`` for a malformed (non-dict) layer.
"""
if not isinstance(layer, dict):
return None
return (
freeze_value(layer.get("sourceType")),
freeze_value(layer.get("value")),
)
def _annotation_layers_modified(
query_context: "QueryContext",
form_data: dict[str, Any],
stored_chart: "Slice",
stored_query_context: Optional[dict[str, Any]],
) -> bool:
"""
Whether the request references annotation layers the stored chart does
not already carry.
``annotation_layers`` is accepted on any query object, and native layers
resolve every annotation of each referenced layer id with no further
access check, so a guest injecting a layer the chart was not saved with
would read data that was never shared with them. Replaying the chart's
own stored layers is not tampering.
"""
requested: set[Optional[tuple[str, str]]] = {
_annotation_layer_identity(layer)
for layer in form_data.get("annotation_layers") or []
}
requested.update(
_annotation_layer_identity(layer)
for query in query_context.queries
for layer in getattr(query, "annotation_layers", None) or []
)
if not requested:
return False
# A malformed (non-dict) layer is nothing the frontend produces from a
# stored chart; treat it as tampering rather than crashing on it later.
if None in requested:
return True
stored: set[Optional[tuple[str, str]]] = {
_annotation_layer_identity(layer)
for layer in stored_chart.params_dict.get("annotation_layers") or []
}
if stored_query_context:
for query in stored_query_context.get("queries") or []:
stored.update(
_annotation_layer_identity(layer)
for layer in query.get("annotation_layers") or []
)
return not requested.issubset(stored)
#: Result types that make the server rewrite the query to return raw rows of
#: every datasource column (``_prepare_samples_query`` and
#: ``_prepare_drill_detail_query`` in ``superset.common.query_actions``).
_ROW_EXPANDING_RESULT_TYPES = {
ChartDataResultType.SAMPLES.value,
ChartDataResultType.DRILL_DETAIL.value,
}
def _result_type_value(result_type: Any) -> str:
"""Normalize a result type (enum member or raw string) to its value."""
return str(getattr(result_type, "value", result_type)).lower()
def _effective_result_type(
query_result_type: Any, default_result_type: Any
) -> Optional[str]:
"""
The result type a query actually runs with: its own ``result_type`` if
set, else the query context's top-level default.
Mirrors ``query_obj.result_type or query_context.result_type``
(``QueryContextProcessor.get_payload``), so this reads the same value the
server uses to pick the samples/drill_detail preparer for that query.
"""
if query_result_type:
return _result_type_value(query_result_type)
if default_result_type:
return _result_type_value(default_result_type)
return None
def _result_type_modified(
query_context: "QueryContext",
stored_query_context: Optional[dict[str, Any]],
) -> bool:
"""
Whether the request asks for a result type that expands one of its
queries to raw datasource rows beyond what the stored chart runs at that
same query position.
The ``samples`` and ``drill_detail`` preparers replace a query's columns
with every column on the datasource - and drop its metrics - *after*
``raise_for_access`` has run, so the subset comparisons on columns and
metrics in ``query_context_modified`` still pass while the response
contains the full underlying table. A guest's entitlement is only what
each query on the stored chart itself renders, so each requested query's
effective result type is compared against its own corresponding stored
query's effective result type by position - never against result types
used by other queries in the same query context - matching how
``query_obj.result_type or query_context.result_type`` is resolved
per-query at runtime.
"""
stored_queries: list[dict[str, Any]] = []
stored_default_result_type: Any = None
if stored_query_context:
stored_default_result_type = stored_query_context.get("result_type")
stored_queries = [
stored_query
for stored_query in stored_query_context.get("queries") or []
if isinstance(stored_query, dict)
]
for index, query in enumerate(query_context.queries):
requested = _effective_result_type(query.result_type, query_context.result_type)
if requested not in _ROW_EXPANDING_RESULT_TYPES:
continue
stored = (
_effective_result_type(
stored_queries[index].get("result_type"), stored_default_result_type
)
if index < len(stored_queries)
else None
)
if requested != stored:
return True
return False
def query_context_modified(query_context: "QueryContext") -> bool:
"""
Check if a query context has been modified.
@@ -1628,7 +1815,10 @@ def query_context_modified(query_context: "QueryContext") -> bool:
# Native-filter data requests have no associated chart (no slice_id). Rather
# than accepting any payload, constrain them to the column(s) the dashboard's
# native filter is allowed to target; other chartless paths keep prior
# behavior (see _native_filter_request_modified).
# behavior (see _native_filter_request_modified), except Drill By is still
# rejected when it asks for a row-expanding result type, since that would
# bypass the drillable-column allowlist raise_for_access checked for it
# (see _drill_by_row_expanding_result_type).
#
# SQL extras (extras.where/having) are NOT validated on chartless paths:
# without a stored chart there is nothing to validate against, and
@@ -1637,7 +1827,9 @@ def query_context_modified(query_context: "QueryContext") -> bool:
# are still protected by datasource-access checks in raise_for_access.
# The _sql_filters_modified check below covers chart payloads only.
if stored_chart is None:
return _native_filter_request_modified(query_context)
return _native_filter_request_modified(
query_context
) or _drill_by_row_expanding_result_type(query_context)
if form_data is None:
return False
@@ -1680,57 +1872,68 @@ def query_context_modified(query_context: "QueryContext") -> bool:
# Use ``is not None`` so an empty-but-present stored context reads as present.
stored_context_state = "present" if stored_query_context is not None else "missing"
# compare columns and metrics in form_data with stored values. Order-by is
# handled separately: a strict subset check there would reject a guest
# legitimately sorting an embedded chart by one of its existing columns.
if _columns_metrics_modified(
query_context, form_data, stored_chart, stored_query_context
):
logger.warning(
"Guest chart payload rejected for slice %s: columns/metrics/group-by "
"not a subset of the stored chart (stored query_context %s)",
stored_chart.id,
stored_context_state,
)
return True
if _series_limit_metric_modified(
query_context,
form_data,
stored_chart,
stored_query_context,
):
logger.warning(
"Guest chart payload rejected for slice %s: series-limit metric not "
"on the stored chart (stored query_context %s)",
stored_chart.id,
stored_context_state,
)
return True
# Order-by may sort only by columns/metrics already present in the stored
# chart; new expressions (e.g. ``random()``) are still rejected.
if _orderby_modified(query_context, stored_chart, stored_query_context):
logger.warning(
"Guest chart payload rejected for slice %s: order-by references a "
"term not on the stored chart (stored query_context %s)",
stored_chart.id,
stored_context_state,
)
return True
# SQL predicates (extras.where/having, SQL adhoc filters) must match
# what was saved on the chart; injected custom SQL is rejected.
if _sql_filters_modified(
query_context, form_data, stored_chart, stored_query_context
):
logger.warning(
"Guest chart payload rejected for slice %s: SQL filter/extras "
"not on the stored chart (stored query_context %s)",
stored_chart.id,
stored_context_state,
)
return True
# Each comparator guards one facet of the payload against the stored chart;
# the first one that objects rejects the request, with its reason logged
# server-side (no payload values) so a 403 is diagnosable.
#
# - result type: reject types that would have the server expand the query
# to raw datasource rows regardless of the stored chart's columns/metrics.
# - columns/metrics/group-by: must be a subset of the stored chart. Order-by
# is handled separately, since a strict subset check there would reject a
# guest legitimately sorting an embedded chart by one of its own columns.
# - order-by: may sort only by columns/metrics already on the stored chart;
# new expressions (e.g. ``random()``) are still rejected.
# - SQL predicates (extras.where/having, SQL adhoc filters): must match what
# was saved on the chart; injected custom SQL is rejected.
# - annotation layers: native layers resolve every annotation of each
# referenced layer with no further access check on this path, so a layer
# the chart was not saved with reads data never shared with the guest.
comparators: list[tuple[Callable[[], bool], str]] = [
(
lambda: _result_type_modified(query_context, stored_query_context),
"result type expands the chart to raw datasource rows",
),
(
lambda: _columns_metrics_modified(
query_context, form_data, stored_chart, stored_query_context
),
"columns/metrics/group-by not a subset of the stored chart",
),
(
lambda: _series_limit_metric_modified(
query_context, form_data, stored_chart, stored_query_context
),
"series-limit metric not on the stored chart",
),
(
lambda: _orderby_modified(
query_context, stored_chart, stored_query_context
),
"order-by references a term not on the stored chart",
),
(
lambda: _sql_filters_modified(
query_context, form_data, stored_chart, stored_query_context
),
"SQL filter/extras not on the stored chart",
),
(
lambda: _annotation_layers_modified(
query_context, form_data, stored_chart, stored_query_context
),
"annotation layer not on the stored chart",
),
]
for is_modified, reason in comparators:
if is_modified():
logger.warning(
"Guest chart payload rejected for slice %s: %s "
"(stored query_context %s)",
stored_chart.id,
reason,
stored_context_state,
)
return True
return False
@@ -138,3 +138,26 @@ def test_get_embedded_dashboard_allows_iframe_sec_fetch_dest(
uri = f"embedded/{embedded.uuid}"
response = client.get(uri, headers={"Sec-Fetch-Dest": "iframe"})
assert response.status_code == 200
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@mock.patch.dict(
"superset.extensions.feature_flag_manager._feature_flags",
EMBEDDED_SUPERSET=True,
)
def test_get_embedded_dashboard_is_neutral_shell(client: FlaskClient[Any]): # noqa: F811
"""The pre-token page must not disclose dashboard metadata.
The Referer / Sec-Fetch-Dest checks are browser cooperation only -- a
non-browser client can forge them -- so anything rendered here is
effectively public. Title and description belong behind the
guest-token-authenticated API.
"""
dash = db.session.query(Dashboard).filter_by(slug="births").first()
dash.description = "internal-only dashboard description"
embedded = EmbeddedDashboardDAO.upsert(dash, [])
db.session.flush()
response = client.get(f"embedded/{embedded.uuid}")
assert response.status_code == 200
assert dash.dashboard_title.encode() not in response.data
assert b"internal-only dashboard description" not in response.data
+218
View File
@@ -29,6 +29,7 @@ from flask_appbuilder.const import AUTH_DB, AUTH_REMOTE_USER
from flask_appbuilder.security.sqla.models import Role, User
from pytest_mock import MockerFixture
from superset.common.chart_data import ChartDataResultType
from superset.common.query_object import QueryObject
from superset.connectors.sqla.models import Database, SqlaTable
from superset.exceptions import SupersetSecurityException
@@ -1405,6 +1406,156 @@ def test_query_context_modified_malformed_stored_query_context(
assert query_context_modified(query_context)
def test_query_context_modified_injected_annotation_layer(
mocker: MockerFixture,
stored_metrics: list[AdhocMetric],
) -> None:
"""
A guest must not be able to inject annotation layers the stored chart
does not carry: native annotation layers resolve all their annotations
with no further access check. Replaying the chart's own stored layers is
not tampering.
"""
layer = {
"annotationType": "INTERVAL",
"sourceType": "NATIVE",
"value": 1,
"name": "Incidents",
}
# replaying the stored layer is allowed
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = None
query_context.slice_.params_dict = {
"metrics": stored_metrics,
"annotation_layers": [layer],
}
query_context.form_data = {
"slice_id": 42,
"metrics": stored_metrics,
"annotation_layers": [layer],
}
query_context.queries = [
QueryObject(metrics=stored_metrics, annotation_layers=[layer]) # type: ignore
]
assert not query_context_modified(query_context)
# injecting a layer the chart was not saved with is tampering
injected = {**layer, "value": 2}
query_context.slice_.params_dict = {
"metrics": stored_metrics,
}
query_context.form_data = {
"slice_id": 42,
"metrics": stored_metrics,
"annotation_layers": [injected],
}
query_context.queries = [
QueryObject(metrics=stored_metrics, annotation_layers=[injected]) # type: ignore
]
assert query_context_modified(query_context)
def test_query_context_modified_result_type_expansion(
mocker: MockerFixture,
stored_metrics: list[AdhocMetric],
) -> None:
"""
Requesting the ``samples``/``drill_detail`` result types is tampering:
the server-side preparers expand those queries to every datasource
column after the subset checks on columns/metrics have run.
"""
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = None
query_context.slice_.params_dict = {
"metrics": stored_metrics,
}
query_context.form_data = {
"slice_id": 42,
"metrics": stored_metrics,
}
query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore
# Top-level result type rewritten to samples.
query_context.result_type = ChartDataResultType.SAMPLES
assert query_context_modified(query_context)
# Per-query result type rewritten to drill_detail.
query_context.result_type = ChartDataResultType.FULL
query_context.queries[0].result_type = ChartDataResultType.DRILL_DETAIL
assert query_context_modified(query_context)
# The chart's own result type is not tampering.
query_context.queries[0].result_type = None
assert not query_context_modified(query_context)
def test_query_context_modified_result_type_per_query_position(
mocker: MockerFixture,
stored_metrics: list[AdhocMetric],
) -> None:
"""
A chart's queries are validated by position: only the query stored with
``samples``/``drill_detail`` may request it, and swapping which query
index carries that result type is tampering even though the type itself
is used somewhere on the stored chart.
"""
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.params_dict = {"metrics": stored_metrics}
query_context.slice_.query_context = json.dumps(
{
"result_type": "full",
"queries": [
{"metrics": stored_metrics, "result_type": "full"},
{"metrics": stored_metrics, "result_type": "samples"},
],
}
)
query_context.form_data = {"slice_id": 42, "metrics": stored_metrics}
query_context.result_type = ChartDataResultType.FULL
# Replaying each query's own stored result type, in the stored order, is
# not tampering.
query_context.queries = [
QueryObject(
metrics=stored_metrics, # type: ignore
result_type=ChartDataResultType.FULL,
),
QueryObject(
metrics=stored_metrics, # type: ignore
result_type=ChartDataResultType.SAMPLES,
),
]
assert not query_context_modified(query_context)
# Requesting `samples` for the query stored at index 0 - a position the
# chart never renders with raw datasource rows - is tampering, even
# though `samples` is the stored result type of a different query.
query_context.queries = [
QueryObject(
metrics=stored_metrics, # type: ignore
result_type=ChartDataResultType.SAMPLES,
),
QueryObject(
metrics=stored_metrics, # type: ignore
result_type=ChartDataResultType.FULL,
),
]
assert query_context_modified(query_context)
# Neither the query nor the query context names a result type: nothing
# resolves to a row-expanding type, so this is not tampering.
query_context.result_type = None
query_context.queries = [
QueryObject(metrics=stored_metrics), # type: ignore
QueryObject(metrics=stored_metrics), # type: ignore
]
assert not query_context_modified(query_context)
def test_query_context_modified_singular_metric_param(
mocker: MockerFixture,
) -> None:
@@ -1769,6 +1920,73 @@ def test_query_context_modified_chartless_non_native_filter_allowed(
assert not query_context_modified(qc)
def test_query_context_modified_drill_by_row_expanding_result_type_blocked(
mocker: MockerFixture,
) -> None:
"""
A Drill By request (``slice_id`` 0 sentinel + source ``chart_id``) is
rejected when it asks for a row-expanding result type: that would bypass
the drillable-dimension allowlist raise_for_access already checked for it
and return every column instead.
"""
query = SimpleNamespace(
columns=[], metrics=[], groupby=["region"], result_type="samples"
)
qc = mocker.MagicMock()
qc.slice_ = None
qc.form_data = {
"dashboardId": 10,
"slice_id": 0,
"chart_id": 5,
"groupby": ["region"],
}
qc.queries = [query]
assert query_context_modified(qc)
query.result_type = "drill_detail"
assert query_context_modified(qc)
def test_query_context_modified_drill_to_detail_row_expanding_result_type_allowed(
mocker: MockerFixture,
) -> None:
"""
Drill to Detail (no ``slice_id``/``chart_id`` at all) is unaffected: it is
already meant to expose every column of a dataset attached to the
dashboard, and is validated by raise_for_access rather than here.
"""
query = SimpleNamespace(
columns=[], metrics=[], groupby=[], result_type="drill_detail"
)
qc = mocker.MagicMock()
qc.slice_ = None
qc.form_data = {"dashboardId": 10}
qc.queries = [query]
assert not query_context_modified(qc)
def test_query_context_modified_native_filter_row_expanding_result_type_blocked(
mocker: MockerFixture,
) -> None:
"""
A native-filter request limited to its target column is still rejected
when it asks for a row-expanding result type (``samples``/``drill_detail``):
those preparers replace the column list with every datasource column,
which would bypass the target-column allowlist entirely.
"""
query = SimpleNamespace(
columns=["region"], metrics=[], groupby=[], result_type="samples"
)
qc = _native_filter_ctx(mocker, [query])
assert query_context_modified(qc)
query = SimpleNamespace(
columns=["region"], metrics=[], groupby=[], result_type="drill_detail"
)
qc = _native_filter_ctx(mocker, [query])
assert query_context_modified(qc)
def test_query_context_modified_native_filter_without_type_marker_blocked(
mocker: MockerFixture,
) -> None: