Compare commits

...

1 Commits

Author SHA1 Message Date
sadpandajoe
c7a62c84fc fix(embedded): allow guests to load charts with missing/stale query_context
Embedded (guest-token) dashboards returned 403 ("Guest user cannot modify
chart payload") on every chart whose saved query_context is NULL or stale.
The frontend's normalizeTimeColumn rewrites a chart's saved x-axis into a
synthesized BASE_AXIS adhoc column; that form never appears verbatim in the
stored params (a physical x-axis is stored as a bare string under its own
x_axis control, not in columns), so the guest anti-tamper subset check in
query_context_modified() rejected legitimate loads. Admin sessions skip the
guard, so the dashboards looked healthy when tested logged-in.

Reduce a synthesized BASE_AXIS column back to the reference it stands for
before the subset check, and treat the chart's stored x_axis as an allowed
dimension (for columns/groupby and order-by). The collapsed value must still
be a subset of owner-trusted stored params/query_context, so tagging an
unrelated column or free-form SQL as BASE_AXIS grants no access. Also log,
server-side, which comparator rejected a guest payload so the failure is
diagnosable instead of an opaque 403.

Fixes apache/superset#31872

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:30:39 +00:00
2 changed files with 411 additions and 6 deletions

View File

@@ -568,6 +568,67 @@ def freeze_value(value: Any) -> str:
return json.dumps(_strip_overridable_keys(value), sort_keys=True)
# Frontend-only markers that ``normalizeTimeColumn`` adds when it synthesizes a
# chart's x-axis into a ``BASE_AXIS`` column. Like ``timeGrain`` they decorate
# the column without changing which data is queried, so they must not count as
# payload tampering.
BASE_AXIS_SYNTHETIC_KEYS = frozenset({"columnType", "isColumnReference"})
def _denormalize_base_axis_column(value: Any) -> Any:
"""
Reduce a synthesized ``BASE_AXIS`` x-axis column back to the reference it
stands for.
Before a chart is queried, ``normalizeTimeColumn`` (superset-ui-core,
``normalizeTimeColumn.ts``) replaces the chart's saved x-axis in the query
with an adhoc column tagged ``columnType: "BASE_AXIS"``:
* for a *physical* x-axis it emits a pure column reference
(``isColumnReference: true`` with ``sqlExpression`` == ``label`` == the
physical column name), and
* for an *adhoc* x-axis it copies the saved adhoc column and adds the
markers (plus the time grain, already dropped by ``freeze_value``).
Neither form selects data beyond the saved x-axis, yet neither appears
verbatim in the chart's stored ``params``/``query_context`` (the x-axis is
stored under its own ``x_axis`` control, and for charts whose saved
``query_context`` is NULL there is nothing to compare against at all). So a
guest merely loading such a chart would otherwise be rejected as a tamperer.
This collapses the synthesized column to its underlying reference, so it
compares equal to the stored x-axis: a physical reference becomes its column
name, and an adhoc reference becomes the underlying adhoc column without the
synthesized markers. The collapsed value must still match a value stored on
the chart, so tagging an unrelated column or free-form SQL as ``BASE_AXIS``
grants no additional access. Non-``BASE_AXIS`` values are returned unchanged.
This has to reduce the value rather than merely drop keys the way
``GUEST_OVERRIDABLE_VALUE_KEYS`` handles ``timeGrain``: a physical x-axis is
stored as a bare string (e.g. ``"order_date"``) but sent as a dict, so no
amount of key-stripping makes the two compare equal; the dict must collapse
back to the string.
"""
if not isinstance(value, dict) or value.get("columnType") != "BASE_AXIS":
return value
if value.get("isColumnReference") and isinstance(value.get("sqlExpression"), str):
return value["sqlExpression"]
return {k: v for k, v in value.items() if k not in BASE_AXIS_SYNTHETIC_KEYS}
def _payload_value_identity(value: Any, *, is_column: bool) -> str:
"""
Comparison identity for a column or metric in the guest anti-tamper check.
Metrics compare by exact frozen value. Columns additionally collapse a
synthesized ``BASE_AXIS`` x-axis to the column it references, so a guest is
not rejected for a column the frontend derived from the chart's own x-axis.
"""
if is_column:
return freeze_value(_denormalize_base_axis_column(value))
return freeze_value(value)
def _native_filter_allowed_targets(
query_context: "QueryContext", form_data: dict[str, Any]
) -> Optional[tuple[set[str], set[str]]]:
@@ -795,6 +856,16 @@ def _collect_sortable_identifiers(
column_config,
is_metric=False,
)
# The x-axis is a visible dimension held under its own control; a guest may
# legitimately sort an embedded chart by it (the request sends it as the
# synthesized BASE_AXIS column, reduced to its reference below).
if params.get("x_axis") is not None:
_add_visible_sort_targets(
allowed,
[params["x_axis"]],
column_config,
is_metric=False,
)
_add_visible_sort_targets(
allowed,
params.get("metrics"),
@@ -982,7 +1053,12 @@ def _orderby_modified(
return True
if freeze_value(entry) in stored_orderby_entries:
continue
if not _requested_sort_target_identifiers(entry[0]) & visible_targets:
# Reduce a synthesized BASE_AXIS x-axis sort target back to the reference
# it stands for, so sorting by the chart's own temporal dimension is not
# read as a new expression. The reduced target must still be a visible
# sort target, so this grants nothing beyond the chart's own columns.
target = _denormalize_base_axis_column(entry[0])
if not _requested_sort_target_identifiers(target) & visible_targets:
return True
return False
@@ -997,16 +1073,33 @@ def _columns_metrics_modified(
Whether the requested columns/metrics/group-by read beyond what the stored
chart exposes. Each requested set must be a subset of the values stored on
the chart (params and, when present, the stored query context).
Column-valued keys compare by an identity that first collapses a
frontend-synthesized ``BASE_AXIS`` x-axis back to the column it references
(see ``_denormalize_base_axis_column``), and additionally allow the chart's
stored ``x_axis``: the x-axis is a saved dimension the query carries in
``columns`` but that is not itself listed under ``columns``/``groupby``.
"""
params = stored_chart.params_dict
for key, equivalent in [
("metrics", ["metrics"]),
("columns", ["columns", "groupby"]),
("groupby", ["columns", "groupby"]),
]:
requested_values = {freeze_value(value) for value in form_data.get(key) or []}
stored_values = {
freeze_value(value) for value in stored_chart.params_dict.get(key) or []
is_column = key != "metrics"
requested_values = {
_payload_value_identity(value, is_column=is_column)
for value in form_data.get(key) or []
}
stored_values = {
_payload_value_identity(value, is_column=is_column)
for value in params.get(key) or []
}
# The x-axis is a stored dimension held under its own control rather than
# in ``columns``/``groupby``, so add it to the allowed set for those keys.
if is_column and (x_axis := params.get("x_axis")) is not None:
stored_values.add(_payload_value_identity(x_axis, is_column=is_column))
# ``form_data`` values are checked against ``params_dict`` alone;
# ``query_context`` values are checked below against the fuller set that
# also includes the stored query context. This asymmetry is intentional:
@@ -1016,7 +1109,7 @@ def _columns_metrics_modified(
# compare queries in query_context
queries_values = {
freeze_value(value)
_payload_value_identity(value, is_column=is_column)
for query in query_context.queries
for value in getattr(query, key, []) or []
}
@@ -1024,7 +1117,8 @@ def _columns_metrics_modified(
for query in stored_query_context.get("queries") or []:
for equiv_key in equivalent:
stored_values.update(
{freeze_value(value) for value in query.get(equiv_key) or []}
_payload_value_identity(value, is_column=is_column)
for value in query.get(equiv_key) or []
)
if not queries_values.issubset(stored_values):
@@ -1054,6 +1148,12 @@ def query_context_modified(query_context: "QueryContext") -> bool:
return False
if form_data.get("slice_id") != stored_chart.id:
logger.warning(
"Guest chart payload rejected for slice %s: requested slice_id %s "
"does not match the chart",
stored_chart.id,
form_data.get("slice_id"),
)
return True
stored_query_context = (
@@ -1062,12 +1162,25 @@ def query_context_modified(query_context: "QueryContext") -> bool:
else None
)
# A rejected guest load is most often a chart whose saved query_context is
# NULL or stale rather than genuine tampering, and the generic 403 gives no
# way to tell which comparator objected. Log that here (server-side only, no
# payload values) so the failure is diagnosable; whether the stored
# query_context was present is the key signal for the missing/stale case.
stored_context_state = "present" if stored_query_context 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(
@@ -1076,11 +1189,23 @@ def query_context_modified(query_context: "QueryContext") -> bool:
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
return False

View File

@@ -348,6 +348,286 @@ def test_raise_for_access_guest_user_tampered_queries_columns(
sm.raise_for_access(query_context=query_context)
def _base_axis_physical_column(name: str, time_grain: str = "P1D") -> dict[str, Any]:
"""
The synthesized x-axis column ``normalizeTimeColumn`` emits for a *physical*
x-axis (see superset-ui-core ``normalizeTimeColumn.ts``): a pure column
reference wrapped with the BASE_AXIS markers and the chart's time grain.
"""
return {
"timeGrain": time_grain,
"columnType": "BASE_AXIS",
"sqlExpression": name,
"label": name,
"expressionType": "SQL",
"isColumnReference": True,
}
def _guest_query(columns: list[Any], metrics: list[AdhocMetric]) -> QueryObject:
"""A QueryObject carrying the given columns/metrics, as a guest request would."""
return QueryObject(columns=columns, metrics=metrics) # type: ignore[arg-type]
def test_raise_for_access_guest_user_ok_base_axis_physical_x_axis(
mocker: MockerFixture,
app_context: None,
stored_metrics: list[AdhocMetric],
) -> None:
"""
A guest may load a chart whose saved ``query_context`` is NULL and whose
x-axis is a physical column. ``normalizeTimeColumn`` sends the x-axis as a
synthesized ``BASE_AXIS`` column that never appears verbatim in the stored
``params`` (the x-axis lives under its own ``x_axis`` control), so before the
carve-out this legitimate load was rejected with a 403.
"""
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_guest_user", return_value=True)
mocker.patch.object(sm, "can_access", return_value=True)
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = None
query_context.slice_.params_dict = {
"x_axis": "order_date",
"metrics": stored_metrics,
"columns": [],
}
query_context.form_data = {
"slice_id": 42,
"x_axis": "order_date",
"metrics": stored_metrics,
"columns": [],
}
query_context.queries = [
_guest_query([_base_axis_physical_column("order_date")], stored_metrics)
]
sm.raise_for_access(query_context=query_context)
def test_raise_for_access_guest_user_ok_base_axis_adhoc_x_axis(
mocker: MockerFixture,
app_context: None,
stored_metrics: list[AdhocMetric],
) -> None:
"""
Same as above for an *adhoc* x-axis: ``normalizeTimeColumn`` copies the saved
adhoc column and adds the BASE_AXIS markers, so the request carries the saved
x-axis plus synthesized decorations and must not read as tampering.
"""
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_guest_user", return_value=True)
mocker.patch.object(sm, "can_access", return_value=True)
adhoc_x_axis: dict[str, Any] = {
"label": "order month",
"sqlExpression": "DATE_TRUNC('month', order_date)",
"expressionType": "SQL",
}
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = None
query_context.slice_.params_dict = {
"x_axis": adhoc_x_axis,
"metrics": stored_metrics,
"columns": [],
}
query_context.form_data = {
"slice_id": 42,
"x_axis": adhoc_x_axis,
"metrics": stored_metrics,
"columns": [],
}
synthesized = {"timeGrain": "P1M", "columnType": "BASE_AXIS", **adhoc_x_axis}
query_context.queries = [_guest_query([synthesized], stored_metrics)]
sm.raise_for_access(query_context=query_context)
def test_raise_for_access_guest_user_base_axis_forged_column_reference(
mocker: MockerFixture,
app_context: None,
stored_metrics: list[AdhocMetric],
) -> None:
"""
The BASE_AXIS carve-out must not become a smuggling channel: a column tagged
``BASE_AXIS`` that references a column the chart never exposed is still
rejected. Here the forged reference points at ``secret_col`` while the stored
x-axis is ``order_date``.
"""
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_guest_user", return_value=True)
mocker.patch.object(sm, "can_access", return_value=True)
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = None
query_context.slice_.params_dict = {
"x_axis": "order_date",
"metrics": stored_metrics,
"columns": [],
}
query_context.form_data = {
"slice_id": 42,
"x_axis": "order_date",
"metrics": stored_metrics,
"columns": [],
}
query_context.queries = [
_guest_query([_base_axis_physical_column("secret_col")], stored_metrics)
]
with pytest.raises(SupersetSecurityException):
sm.raise_for_access(query_context=query_context)
def test_raise_for_access_guest_user_base_axis_forged_adhoc_expression(
mocker: MockerFixture,
app_context: None,
stored_metrics: list[AdhocMetric],
) -> None:
"""
A BASE_AXIS-tagged adhoc column whose SQL body differs from the saved x-axis
is rejected: tagging free-form SQL as BASE_AXIS must not authorize it.
"""
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_guest_user", return_value=True)
mocker.patch.object(sm, "can_access", return_value=True)
adhoc_x_axis: dict[str, Any] = {
"label": "order month",
"sqlExpression": "DATE_TRUNC('month', order_date)",
"expressionType": "SQL",
}
forged = {
"timeGrain": "P1M",
"columnType": "BASE_AXIS",
"label": "order month",
"sqlExpression": "list_secret()",
"expressionType": "SQL",
}
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = None
query_context.slice_.params_dict = {
"x_axis": adhoc_x_axis,
"metrics": stored_metrics,
"columns": [],
}
query_context.form_data = {
"slice_id": 42,
"x_axis": adhoc_x_axis,
"metrics": stored_metrics,
"columns": [],
}
query_context.queries = [_guest_query([forged], stored_metrics)]
with pytest.raises(SupersetSecurityException):
sm.raise_for_access(query_context=query_context)
def test_raise_for_access_guest_user_ok_base_axis_stale_query_context(
mocker: MockerFixture,
app_context: None,
stored_metrics: list[AdhocMetric],
) -> None:
"""
A chart with a *stale* (non-NULL) saved ``query_context`` from an older
frontend that predates the BASE_AXIS column still loads: the x-axis carve-out
is sourced from ``params`` rather than the stored ``query_context``, so a
stored context that lacks the synthesized column does not reject the guest.
"""
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_guest_user", return_value=True)
mocker.patch.object(sm, "can_access", return_value=True)
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = json.dumps(
{"queries": [{"columns": [], "metrics": stored_metrics}]}
)
query_context.slice_.params_dict = {
"x_axis": "order_date",
"metrics": stored_metrics,
"columns": [],
}
query_context.form_data = {
"slice_id": 42,
"x_axis": "order_date",
"metrics": stored_metrics,
"columns": [],
}
query_context.queries = [
_guest_query([_base_axis_physical_column("order_date")], stored_metrics)
]
sm.raise_for_access(query_context=query_context)
def test_raise_for_access_guest_user_ok_base_axis_orderby(
mocker: MockerFixture,
app_context: None,
stored_metrics: list[AdhocMetric],
) -> None:
"""
A guest may sort an embedded chart by its own temporal x-axis, which the
frontend sends as the synthesized BASE_AXIS column in the order-by term.
"""
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_guest_user", return_value=True)
mocker.patch.object(sm, "can_access", return_value=True)
base_axis = _base_axis_physical_column("order_date")
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = None
query_context.slice_.params_dict = {
"x_axis": "order_date",
"metrics": stored_metrics,
"columns": [],
}
query_context.form_data = {
"slice_id": 42,
"x_axis": "order_date",
"metrics": stored_metrics,
"columns": [],
"orderby": [[base_axis, True]],
}
query_context.queries = [_guest_query([base_axis], stored_metrics)]
sm.raise_for_access(query_context=query_context)
def test_raise_for_access_guest_user_base_axis_orderby_forged(
mocker: MockerFixture,
app_context: None,
stored_metrics: list[AdhocMetric],
) -> None:
"""
The order-by carve-out is bounded: sorting by a BASE_AXIS-tagged column that
references a column the chart never exposed is still rejected.
"""
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_guest_user", return_value=True)
mocker.patch.object(sm, "can_access", return_value=True)
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = None
query_context.slice_.params_dict = {
"x_axis": "order_date",
"metrics": stored_metrics,
"columns": [],
}
query_context.form_data = {
"slice_id": 42,
"x_axis": "order_date",
"metrics": stored_metrics,
"columns": [],
"orderby": [[_base_axis_physical_column("secret_col"), True]],
}
query_context.queries = [
_guest_query([_base_axis_physical_column("order_date")], stored_metrics)
]
with pytest.raises(SupersetSecurityException):
sm.raise_for_access(query_context=query_context)
def test_raise_for_access_query_default_schema(
mocker: MockerFixture,
app_context: None,