mirror of
https://github.com/apache/superset.git
synced 2026-09-09 08:44:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39b8693822 | ||
|
|
c89e5edefc | ||
|
|
05dc360b28 | ||
|
|
32e3b5aba6 | ||
|
|
bac3e50a11 | ||
|
|
b066cdb894 | ||
|
|
e08790c951 | ||
|
|
f452ecc498 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user