This is an automated email from the ASF dual-hosted git repository.
sha174n pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 4d8dfdd1520 fix(sqllab): restrict TabStateView updates to owned
columns and queries (#43992)
4d8dfdd1520 is described below
commit 4d8dfdd1520d9b26c0b713b0d707481d36526665
Author: Shaitan <[email protected]>
AuthorDate: Wed Sep 9 14:03:30 2026 +0100
fix(sqllab): restrict TabStateView updates to owned columns and queries
(#43992)
Co-authored-by: sha174n <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
superset/views/sql_lab/views.py | 65 ++++++-
.../views/test_sql_lab_tab_state_views.py | 203 ++++++++++++++++++++-
2 files changed, 253 insertions(+), 15 deletions(-)
diff --git a/superset/views/sql_lab/views.py b/superset/views/sql_lab/views.py
index 7a12f5b20df..a4a85091207 100644
--- a/superset/views/sql_lab/views.py
+++ b/superset/views/sql_lab/views.py
@@ -21,7 +21,7 @@ from flask import request, Response
from flask_appbuilder import expose
from flask_appbuilder.security.decorators import has_access, has_access_api
from flask_babel import gettext as __
-from sqlalchemy import and_
+from sqlalchemy import and_, or_
from superset import db
from superset.models.sql_lab import Query, TableSchema, TabState
@@ -51,6 +51,29 @@ def _get_tab_user_id(tab_state_id: int) -> int | None:
return
db.session.query(TabState.user_id).filter_by(id=tab_state_id).scalar()
+# Columns a client may set through ``TabStateView.put``: the fields the SQL Lab
+# editor auto-sync sends, plus ``saved_query_id``. Identity columns (``id``,
+# ``user_id``) are excluded so a tab state stays bound to its creating user,
and
+# ``active`` is excluded because ``activate`` owns it -- that endpoint rewrites
+# the flag across all of the user's tabs to keep exactly one of them active.
+_TAB_STATE_PUT_FIELDS = frozenset(
+ {
+ "label",
+ "database_id",
+ "catalog",
+ "schema",
+ "sql",
+ "query_limit",
+ "latest_query_id",
+ "autorun",
+ "template_params",
+ "hide_left_bar",
+ "saved_query_id",
+ "extra_json",
+ }
+)
+
+
class TabStateView(BaseSupersetView):
@has_access_api
@expose("/", methods=("POST",))
@@ -146,14 +169,33 @@ class TabStateView(BaseSupersetView):
@has_access_api
@expose("<int:tab_state_id>", methods=("PUT",))
def put(self, tab_state_id: int) -> FlaskResponse:
+ user_id = get_user_id()
tab_user_id = _get_tab_user_id(tab_state_id)
if tab_user_id is None:
return Response(status=404)
- if tab_user_id != get_user_id():
+ if tab_user_id != user_id:
return Response(status=403)
try:
- fields = {k: json.loads(v) for k, v in
request.form.to_dict().items()}
+ fields = {
+ k: json.loads(v)
+ for k, v in request.form.to_dict().items()
+ if k in _TAB_STATE_PUT_FIELDS
+ }
+ # Drop latest_query_id only when it points at a query owned by a
+ # different user; the caller's own and unowned queries are fine.
+ if (latest_query_id := fields.get("latest_query_id")) is not None:
+ owned_by_other = (
+ db.session.query(Query.id)
+ .filter(
+ Query.client_id == latest_query_id,
+ Query.user_id.isnot(None),
+ Query.user_id != user_id,
+ )
+ .first()
+ )
+ if owned_by_other:
+ del fields["latest_query_id"]
db.session.query(TabState).filter_by(id=tab_state_id).update(fields)
db.session.commit()
return json_success(json.dumps(tab_state_id))
@@ -165,16 +207,27 @@ class TabStateView(BaseSupersetView):
@expose("<int:tab_state_id>/migrate_query", methods=("POST",))
def migrate_query(self, tab_state_id: int) -> FlaskResponse:
try:
+ user_id = get_user_id()
tab_user_id = _get_tab_user_id(tab_state_id)
if tab_user_id is None:
return Response(status=404)
- if tab_user_id != get_user_id():
+ if tab_user_id != user_id:
return Response(status=403)
client_id = json.loads(request.form["queryId"])
- db.session.query(Query).filter_by(client_id=client_id).update(
- {"sql_editor_id": tab_state_id}
+ rebound = (
+ db.session.query(Query)
+ .filter(
+ Query.client_id == client_id,
+ or_(Query.user_id == user_id, Query.user_id.is_(None)),
+ )
+ .update({"sql_editor_id": tab_state_id})
)
+ if not rebound:
+ # No query the caller may rebind matches this client_id, so
+ # report the miss instead of a success the client would use to
+ # update its own state.
+ return Response(status=404)
db.session.commit()
return json_success(json.dumps(tab_state_id))
except Exception as ex: # pylint: disable=broad-except
diff --git a/tests/unit_tests/views/test_sql_lab_tab_state_views.py
b/tests/unit_tests/views/test_sql_lab_tab_state_views.py
index d8ca5d84521..5b55f53ee2f 100644
--- a/tests/unit_tests/views/test_sql_lab_tab_state_views.py
+++ b/tests/unit_tests/views/test_sql_lab_tab_state_views.py
@@ -25,12 +25,40 @@ authenticated HTTP session. Only the ownership-check logic
inside the view
methods themselves is under test here.
"""
+import pytest
+from flask import current_app
from sqlalchemy.orm.session import Session
from superset.models.sql_lab import Query, TabState
from superset.views.sql_lab.views import TabStateView
[email protected](autouse=True)
+def tab_state_tables(session: Session) -> None:
+ """``TabState`` and ``Query`` share the declarative metadata."""
+ TabState.metadata.create_all(session.get_bind()) # pylint:
disable=no-member
+
+
+def _add_query(
+ session: Session,
+ *,
+ client_id: str,
+ user_id: int | None,
+ sql_editor_id: str,
+ sql: str = "SELECT 1",
+) -> Query:
+ query = Query(
+ client_id=client_id,
+ database_id=1,
+ user_id=user_id,
+ sql_editor_id=sql_editor_id,
+ sql=sql,
+ )
+ session.add(query)
+ session.flush()
+ return query
+
+
def _create_tab_state_and_query(
session: Session,
*,
@@ -47,15 +75,12 @@ def _create_tab_state_and_query(
session.add(tab_state)
session.flush()
- query = Query(
+ query = _add_query(
+ session,
client_id=latest_query_client_id,
- database_id=1,
user_id=owner_id,
sql_editor_id=str(tab_state.id),
- sql="SELECT 1",
)
- session.add(query)
- session.flush()
return tab_state, query
@@ -71,8 +96,6 @@ def test_delete_query_rejects_update_from_non_owning_user(
rejected before either the ``TabState`` update or the ``Query`` row
deletion happens.
"""
- TabState.metadata.create_all(session.get_bind()) # pylint:
disable=no-member
-
owner_id = 2
other_user_id = 1
@@ -111,8 +134,6 @@ def test_put_rejects_update_from_non_owning_user(session:
Session, mocker) -> No
is included for contrast with ``delete_query`` above -- the guard exists
elsewhere in this class, it's just missing on the ``delete_query`` path.
"""
- TabState.metadata.create_all(session.get_bind()) # pylint:
disable=no-member
-
owner_id = 2
other_user_id = 1
@@ -127,3 +148,167 @@ def test_put_rejects_update_from_non_owning_user(session:
Session, mocker) -> No
response = TabStateView.put.__wrapped__(view, tab_state.id)
assert response.status_code == 403
+
+
+def test_put_ignores_columns_outside_the_allowlist(session: Session, mocker)
-> None:
+ """
+ ``put`` writes only the client-updatable columns; identity columns such
+ as ``user_id`` are not accepted from the request body, so the tab state
+ stays bound to its creating user while allowed fields still apply.
+ """
+ owner_id = 2
+ tab_state, _query = _create_tab_state_and_query(
+ session, owner_id=owner_id, latest_query_client_id="owner-query-1"
+ )
+ session.commit()
+
+ mocker.patch("superset.views.sql_lab.views.get_user_id",
return_value=owner_id)
+
+ view = TabStateView()
+ with current_app.test_request_context(
+ method="PUT", data={"label": '"renamed"', "user_id": "1"}
+ ):
+ TabStateView.put.__wrapped__(view, tab_state.id)
+
+ session.expire_all()
+ refreshed = session.query(TabState).filter_by(id=tab_state.id).one()
+ assert refreshed.label == "renamed"
+ assert refreshed.user_id == owner_id
+
+
+def test_put_writes_nothing_for_a_payload_of_only_denied_columns(
+ session: Session, mocker
+) -> None:
+ """
+ A payload made up entirely of non-updatable columns is accepted but
+ changes none of them, rather than erroring or falling through to a write.
+ """
+ owner_id = 2
+ tab_state, _query = _create_tab_state_and_query(
+ session, owner_id=owner_id, latest_query_client_id="owner-query-1"
+ )
+ session.commit()
+
+ mocker.patch("superset.views.sql_lab.views.get_user_id",
return_value=owner_id)
+
+ view = TabStateView()
+ with current_app.test_request_context(method="PUT", data={"user_id": "1"}):
+ response = TabStateView.put.__wrapped__(view, tab_state.id)
+
+ assert response.status_code == 200
+
+ session.expire_all()
+ refreshed = session.query(TabState).filter_by(id=tab_state.id).one()
+ assert refreshed.user_id == owner_id
+ assert refreshed.label == "unrelated tab"
+
+
+def test_put_leaves_the_active_flag_to_the_activate_endpoint(
+ session: Session, mocker
+) -> None:
+ """
+ ``activate`` keeps exactly one of a user's tabs active by rewriting the
+ flag across all of their rows, so ``put`` does not accept ``active``.
+ Sending it leaves the stored flag alone while allowed fields still apply.
+ """
+ owner_id = 2
+ tab_state, _query = _create_tab_state_and_query(
+ session, owner_id=owner_id, latest_query_client_id="owner-query-1"
+ )
+ tab_state.active = False
+ session.commit()
+
+ mocker.patch("superset.views.sql_lab.views.get_user_id",
return_value=owner_id)
+
+ view = TabStateView()
+ with current_app.test_request_context(
+ method="PUT", data={"active": "true", "label": '"renamed"'}
+ ):
+ TabStateView.put.__wrapped__(view, tab_state.id)
+
+ session.expire_all()
+ refreshed = session.query(TabState).filter_by(id=tab_state.id).one()
+ assert refreshed.active is False
+ assert refreshed.label == "renamed"
+
+
+def test_put_only_accepts_latest_query_id_owned_by_caller(
+ session: Session, mocker
+) -> None:
+ """
+ A ``latest_query_id`` is stored only when it references the caller's own
+ query. A client_id belonging to another user is dropped, so the tab's
+ pointer keeps referencing the caller's own query.
+ """
+ owner_id = 2
+ other_user_id = 1
+ tab_state, _query = _create_tab_state_and_query(
+ session, owner_id=owner_id, latest_query_client_id="owner-query-1"
+ )
+ _add_query(
+ session,
+ client_id="foreign-query-1",
+ user_id=other_user_id,
+ sql_editor_id="999",
+ sql="SELECT secret",
+ )
+ session.commit()
+
+ mocker.patch("superset.views.sql_lab.views.get_user_id",
return_value=owner_id)
+
+ view = TabStateView()
+ with current_app.test_request_context(
+ method="PUT", data={"latest_query_id": '"foreign-query-1"'}
+ ):
+ TabStateView.put.__wrapped__(view, tab_state.id)
+
+ session.expire_all()
+ refreshed = session.query(TabState).filter_by(id=tab_state.id).one()
+ assert refreshed.latest_query_id == "owner-query-1"
+
+
[email protected](
+ "query_owner_id,expected_status,rebound",
+ [
+ pytest.param(1, 404, False, id="another_users_query_is_left_alone"),
+ pytest.param(2, 200, True, id="own_query_is_rebound"),
+ pytest.param(None, 200, True, id="unowned_query_is_rebound"),
+ ],
+)
+def test_migrate_query_scopes_the_rebind_to_the_caller(
+ session: Session,
+ mocker,
+ query_owner_id: int | None,
+ expected_status: int,
+ rebound: bool,
+) -> None:
+ """
+ ``migrate_query`` rebinds a query to a tab only when the caller owns that
+ query or nobody does. A client_id owned by another user is left untouched
+ and the caller gets a 404 rather than a success it would apply locally.
+ """
+ owner_id = 2
+ tab_state, _query = _create_tab_state_and_query(
+ session, owner_id=owner_id, latest_query_client_id="owner-query-1"
+ )
+ _add_query(
+ session,
+ client_id="target-query",
+ user_id=query_owner_id,
+ sql_editor_id="88",
+ )
+ session.commit()
+
+ mocker.patch("superset.views.sql_lab.views.get_user_id",
return_value=owner_id)
+
+ view = TabStateView()
+ with current_app.test_request_context(
+ method="POST", data={"queryId": '"target-query"'}
+ ):
+ response = TabStateView.migrate_query.__wrapped__(view, tab_state.id)
+
+ assert response.status_code == expected_status
+
+ session.expire_all()
+ target = session.query(Query).filter_by(client_id="target-query").one()
+ assert target.sql_editor_id == (str(tab_state.id) if rebound else "88")