Compare commits

...

2 Commits

Author SHA1 Message Date
Evan
7c1fa4ae72 fix(embedded): include legacy singular metric and reject malformed orderby in guest sort guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:00:13 -07:00
Claude Code
27c481d75d fix(embedded): allow guest users to sort table columns
query_context_modified() compared a guest request's orderby against the
stored chart's orderby with a strict subset check, alongside columns,
metrics and groupby. Saved charts usually store an empty orderby, so when
a guest clicked a column header to sort a table in an embedded dashboard
the new orderby value failed the subset check and the request was
rejected with "Guest user cannot modify chart payload", even though
sorting only changes result ordering and not which data is read.

Handle orderby separately: a guest may sort by any column or metric the
stored chart already references, but order-by terms that are not present
in the stored chart (e.g. a free-form random() expression) are still
rejected, preserving the existing SQL-injection guard. The columns/
metrics/groupby comparison is extracted into a helper to keep
query_context_modified within the complexity budget, and the inner loop's
variable shadowing of `key` is fixed along the way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 21:46:53 -07:00
2 changed files with 227 additions and 29 deletions

View File

@@ -520,6 +520,125 @@ def _native_filter_request_modified(query_context: "QueryContext") -> bool:
)
def _collect_sortable_identifiers(
stored_chart: "Slice",
stored_query_context: Optional[dict[str, Any]],
) -> set[str]:
"""
Frozen column names and metric labels/definitions a guest may legitimately
sort by: every column or metric the stored chart already references.
Order-by only changes the ordering of the result, not which data is read, so
any column or metric already part of the chart is a safe sort target. A term
that is not present in the stored chart (for example a free-form ``random()``
expression) cannot be validated and must be rejected. Order-by entries are
``(column_or_metric, ascending)`` pairs, so only their first element is
collected.
"""
allowed: set[str] = set()
def add(values: Any) -> None:
for value in values or []:
allowed.add(freeze_value(value))
def add_orderby(entries: Any) -> None:
for entry in entries or []:
if isinstance(entry, (list, tuple)) and entry:
allowed.add(freeze_value(entry[0]))
params = stored_chart.params_dict
for key in ("columns", "groupby", "metrics", "all_columns"):
add(params.get(key))
# Legacy charts store a single metric under the singular ``metric`` key.
add([params["metric"]] if params.get("metric") is not None else None)
add_orderby(params.get("orderby"))
if stored_query_context:
for query in stored_query_context.get("queries") or []:
for key in ("columns", "groupby", "metrics"):
add(query.get(key))
add_orderby(query.get("orderby"))
return allowed
def _orderby_modified(
query_context: "QueryContext",
stored_chart: "Slice",
stored_query_context: Optional[dict[str, Any]],
) -> bool:
"""
Whether any order-by clause sorts by a term the stored chart does not already
reference.
A guest reordering an embedded chart by one of its existing columns or
metrics is legitimate and must not read as tampering; introducing a new
expression is not, and is rejected.
"""
allowed = _collect_sortable_identifiers(stored_chart, stored_query_context)
form_data = query_context.form_data or {}
requested = list(form_data.get("orderby") or [])
for query in query_context.queries:
requested.extend(getattr(query, "orderby", None) or [])
for entry in requested:
# Order-by entries must be ``(column_or_metric, ascending)`` pairs. A
# malformed shape (e.g. a bare string or nested list) is not a valid
# sort the chart could have produced, so treat it as tampering rather
# than letting it crash query building when it is later unpacked.
if not (
isinstance(entry, (list, tuple))
and len(entry) == 2
and isinstance(entry[1], bool)
):
return True
if freeze_value(entry[0]) not in allowed:
return True
return False
def _columns_metrics_modified(
query_context: "QueryContext",
form_data: dict[str, Any],
stored_chart: "Slice",
stored_query_context: Optional[dict[str, Any]],
) -> bool:
"""
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).
"""
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 []
}
if not requested_values.issubset(stored_values):
return True
# compare queries in query_context
queries_values = {
freeze_value(value)
for query in query_context.queries
for value in getattr(query, key, []) or []
}
if stored_query_context:
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 []}
)
if not queries_values.issubset(stored_values):
return True
return False
def query_context_modified(query_context: "QueryContext") -> bool:
"""
Check if a query context has been modified.
@@ -550,35 +669,18 @@ def query_context_modified(query_context: "QueryContext") -> bool:
else None
)
# compare columns and metrics in form_data with stored values
for key, equivalent in [
("metrics", ["metrics"]),
("columns", ["columns", "groupby"]),
("groupby", ["columns", "groupby"]),
("orderby", ["orderby"]),
]:
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 []
}
if not requested_values.issubset(stored_values):
return True
# 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
):
return True
# compare queries in query_context
queries_values = {
freeze_value(value)
for query in query_context.queries
for value in getattr(query, key, []) or []
}
if stored_query_context:
for query in stored_query_context.get("queries") or []:
for key in equivalent:
stored_values.update(
{freeze_value(value) for value in query.get(key) or []}
)
if not queries_values.issubset(stored_values):
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):
return True
return False

View File

@@ -19,7 +19,7 @@
import json # noqa: TID251
from types import SimpleNamespace
from typing import Any
from typing import Any, Optional
import pytest
from flask_appbuilder.security.sqla.models import Role, User
@@ -1218,6 +1218,102 @@ def test_query_context_modified_orderby(mocker: MockerFixture) -> None:
assert query_context_modified(query_context)
def _table_sort_query_context(
mocker: MockerFixture,
orderby: list[Any],
*,
stored_metrics: Optional[list[Any]] = None,
with_stored_query_context: bool = True,
) -> Any:
"""
Build a minimal table-chart query context for a guest that sorts by
``orderby``. The stored chart groups by ``gender`` and aggregates ``count``.
"""
metrics: list[Any] = stored_metrics if stored_metrics is not None else ["count"]
query_context = mocker.MagicMock()
query_context.queries = [
QueryObject(columns=["gender"], metrics=metrics, orderby=orderby),
]
query_context.form_data = {"slice_id": 101, "groupby": ["gender"]}
query_context.slice_.id = 101
query_context.slice_.params_dict = {"groupby": ["gender"], "metrics": metrics}
query_context.slice_.query_context = (
json.dumps({"queries": [{"columns": ["gender"], "metrics": metrics}]})
if with_stored_query_context
else None
)
return query_context
def test_query_context_modified_orderby_sort_by_column(mocker: MockerFixture) -> None:
"""A guest sorting an embedded table by an existing column is allowed."""
query_context = _table_sort_query_context(mocker, orderby=[("gender", True)])
assert not query_context_modified(query_context)
def test_query_context_modified_orderby_sort_by_metric(mocker: MockerFixture) -> None:
"""A guest sorting by an existing metric is allowed."""
query_context = _table_sort_query_context(mocker, orderby=[("count", False)])
assert not query_context_modified(query_context)
def test_query_context_modified_orderby_sort_by_adhoc_metric(
mocker: MockerFixture,
) -> None:
"""A guest sorting by an existing adhoc metric definition is allowed."""
adhoc_metric: AdhocMetric = {
"aggregate": "SUM",
"column": {"column_name": "num"},
"expressionType": "SIMPLE",
"hasCustomLabel": False,
"label": "SUM(num)",
}
query_context = _table_sort_query_context(
mocker,
orderby=[(adhoc_metric, False)],
stored_metrics=[adhoc_metric],
)
assert not query_context_modified(query_context)
def test_query_context_modified_orderby_unknown_column(mocker: MockerFixture) -> None:
"""A guest sorting by a column the chart does not reference is rejected."""
query_context = _table_sort_query_context(mocker, orderby=[("salary", True)])
assert query_context_modified(query_context)
def test_query_context_modified_orderby_empty(mocker: MockerFixture) -> None:
"""An empty order-by is not a modification."""
query_context = _table_sort_query_context(mocker, orderby=[])
assert not query_context_modified(query_context)
def test_query_context_modified_orderby_legacy_singular_metric(
mocker: MockerFixture,
) -> None:
"""A guest sorting by a legacy ``metric`` (singular) field is allowed."""
query_context = _table_sort_query_context(
mocker,
orderby=[("num_sold", False)],
stored_metrics=[],
with_stored_query_context=False,
)
query_context.slice_.params_dict = {
"columns": ["gender"],
"groupby": ["gender"],
"metric": "num_sold",
}
assert not query_context_modified(query_context)
def test_query_context_modified_orderby_malformed_entry(
mocker: MockerFixture,
) -> None:
"""A malformed order-by entry (not a ``(term, bool)`` pair) is rejected."""
query_context = _table_sort_query_context(mocker, orderby=[["gender"]])
assert query_context_modified(query_context)
def test_query_context_modified_time_grain_native_filter(
mocker: MockerFixture,
) -> None: