Compare commits

...
Author SHA1 Message Date
Superset DevandClaude Sonnet 5 0ba34c97a0 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-08-21 13:43:30 -07:00
Superset Dev a8284ec014 Merge remote-tracking branch 'origin/master' into worktree-agent-aa6eed447e8f5a7bf
# Conflicts:
#	superset/security/manager.py
2026-08-21 09:46:51 -07:00
Superset Dev 08935144c8 fix(async-events): derive guest channel id from the full token claim set
get_guest_user_channel_id() HMACed an enumerated subset of guest-token
claims (user/resources/iat/exp/aud/datasets/rev), omitting rls_rules.
Two guest tokens differing only in their RLS clauses -- the documented
pattern for embedding one dashboard across multiple tenants -- could
derive the same async channel and collide on job-status polling and
cancellation. HMAC the complete decoded claim set instead of an
enumerated subset, so any claim difference (including future ones)
yields a distinct channel.
2026-08-21 00:48:59 -07:00
Superset Dev 9b1f66823b 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-08-21 00:47:02 -07:00
Superset Dev e5ed37a333 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-08-21 00:41:12 -07:00
Superset Dev 6f96d1121f 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-08-21 00:39:21 -07:00
6 changed files with 394 additions and 21 deletions
+11 -18
View File
@@ -238,24 +238,17 @@ class AsyncQueryManager:
secret so the value is unguessable to outside callers.
"""
token = guest_user.guest_token
# ``iat`` uniquely identifies a guest token issuance, so it provides
# per-token isolation while remaining stable across the lifetime of a
# single embedded session.
message = json.dumps(
{
"user": token.get("user"),
"resources": token.get("resources"),
"iat": token.get("iat"),
"exp": token.get("exp"),
"aud": token.get("aud"),
# ``datasets`` and ``rev`` are optional scope claims, so tokens
# that differ only in their dataset allowlist or revocation
# version still derive distinct channels.
"datasets": token.get("datasets"),
"rev": token.get("rev"),
},
sort_keys=True,
).encode("utf-8")
# HMAC over the complete claim set so that tokens differing in *any*
# claim derive distinct channels. Enumerating claims here is unsafe:
# omitting one that scopes the session -- most importantly
# ``rls_rules``, the primary tenant-isolation mechanism for embedded
# dashboards -- would let two tenants' tokens minted in the same
# second with identical user/resources collide on one channel,
# exposing job events (including error strings) and cross-tenant
# cancellation. ``iat`` uniquely identifies a token issuance, so it
# provides per-token isolation while remaining stable across the
# lifetime of a single embedded session.
message = json.dumps(token, sort_keys=True).encode("utf-8")
digest = hmac.new(
self._jwt_secret.encode("utf-8"), message, hashlib.sha256
).hexdigest()
+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
),
+166
View File
@@ -73,6 +73,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 (
@@ -1562,6 +1563,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.
@@ -1613,6 +1754,17 @@ 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"
# Reject result types that would have the server expand the query to raw
# datasource rows regardless of the stored chart's columns and metrics.
if _result_type_modified(query_context, stored_query_context):
logger.warning(
"Guest chart payload rejected for slice %s: result type expands "
"the chart to raw datasource rows (stored query_context %s)",
stored_chart.id,
stored_context_state,
)
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.
@@ -1652,6 +1804,20 @@ def query_context_modified(query_context: "QueryContext") -> bool:
)
return True
# Native annotation 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 that was never shared with the guest.
if _annotation_layers_modified(
query_context, form_data, stored_chart, stored_query_context
):
logger.warning(
"Guest chart payload rejected for slice %s: annotation layer 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(
@@ -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
@@ -263,6 +263,44 @@ def test_parse_channel_id_from_request_as_guest_user_differs_per_scope(
assert with_datasets != with_rev
@mock.patch("superset.is_feature_enabled")
def test_parse_channel_id_from_request_as_guest_user_differs_per_rls_rules(
is_feature_enabled_mock, async_query_manager
):
"""
Tokens that differ only in their ``rls_rules`` must derive distinct
channel ids. Two tenants embedded via the same dashboard are typically
minted tokens with identical user/resources blocks and, with an
externally minted integer-second ``iat``, the same issuance time; if the
channel ignored ``rls_rules``, tenant B could observe tenant A's job
events and cancel A's jobs.
"""
is_feature_enabled_mock.return_value = True
base_token = {
"user": {},
"resources": [{"type": "dashboard", "id": "some-uuid"}],
"rls_rules": [{"clause": '"TENANT_ID" = 1'}],
"iat": 1700000000.0,
"exp": 1700000300.0,
"aud": "http://0.0.0.0:8080/",
"type": "guest",
}
request = Mock()
request.cookies = {}
g.user = security_manager.get_guest_user_from_token(dict(base_token))
tenant_a = async_query_manager.parse_channel_id_from_request(request)
g.user = security_manager.get_guest_user_from_token(
{**base_token, "rls_rules": [{"clause": '"TENANT_ID" = 2'}]}
)
tenant_b = async_query_manager.parse_channel_id_from_request(request)
assert tenant_a != tenant_b
@mark.parametrize(
"cache_type, cache_backend",
[
+151
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
@@ -1381,6 +1382,156 @@ def test_query_context_modified_tampered(
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: