mirror of
https://github.com/apache/superset.git
synced 2026-07-18 04:35:40 +00:00
Compare commits
1 Commits
dependabot
...
fix/embedd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7a62c84fc |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user