Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: prevent guest user from modifying metrics #26749

Merged
merged 1 commit into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion superset/common/query_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ def __init__( # pylint: disable=too-many-locals
order_desc: bool = True,
orderby: list[OrderBy] | None = None,
post_processing: list[dict[str, Any] | None] | None = None,
row_limit: int | None,
row_limit: int | None = None,
row_offset: int | None = None,
series_columns: list[Column] | None = None,
series_limit: int = 0,
Expand Down
26 changes: 25 additions & 1 deletion superset/security/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1818,7 +1818,7 @@ def get_exclude_users_from_lists() -> list[str]:
return []

def raise_for_access(
# pylint: disable=too-many-arguments,too-many-branches,too-many-locals
# pylint: disable=too-many-arguments,too-many-branches,too-many-locals,too-many-statements
self,
dashboard: Optional["Dashboard"] = None,
database: Optional["Database"] = None,
Expand Down Expand Up @@ -1908,6 +1908,30 @@ def raise_for_access(
self.get_table_access_error_object(denied)
)

if self.is_guest_user() and query_context:
# Guest users MUST not modify the payload so it's requesting a different
# chart or different ad-hoc metrics from what's saved.
form_data = query_context.form_data
stored_chart = query_context.slice_

if (
form_data is None
or stored_chart is None
or form_data.get("slice_id") != stored_chart.id
or form_data.get("metrics", []) != stored_chart.params_dict["metrics"]
or any(
query.metrics != stored_chart.params_dict["metrics"]
for query in query_context.queries
)
):
raise SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DASHBOARD_SECURITY_ACCESS_ERROR,
message=_("Guest user cannot modify chart payload"),
level=ErrorLevel.ERROR,
Copy link
Member

@eschutho eschutho Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar question as here: https://github.com/apache/superset/pull/26748/files#diff-30f4c6ffdcb1f78a9e1ebbb60e1f297b379c181534d5a185a4cd37b1b16ac6f8R301
Does this raise a 5xx error? I think these are generally user-generated, so we would want to know about them, but be able to distinguish them from a system error. Is that correct?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this logic impact queries to fetch filter values? Also, are we concerned with the dimensions as well (or un-aggregated columns for table charts)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good point. Let me verify!

)
)

if datasource or query_context or viz:
form_data = None

Expand Down
15 changes: 15 additions & 0 deletions tests/integration_tests/security/guest_token_security_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,10 @@ def test_raise_for_access__happy_path(self):
form_data={
"dashboardId": self.dash.id,
"slice_id": self.chart.id,
"metrics": self.chart.params_dict["metrics"],
},
slice_=self.chart,
queries=[],
)
}
)
Expand All @@ -304,7 +307,11 @@ def test_raise_for_access__native_filter_happy_path(self):
"dashboardId": self.dash.id,
"native_filter_id": "NATIVE_FILTER-ABCDEFGH",
"type": "NATIVE_FILTER",
"slice_id": self.chart.id,
"metrics": self.chart.params_dict["metrics"],
},
slice_=self.chart,
queries=[],
)
}
)
Expand Down Expand Up @@ -382,7 +389,11 @@ def test_raise_for_access__native_filter_no_id_in_form_data(self):
form_data={
"dashboardId": self.dash.id,
"type": "NATIVE_FILTER",
"slice_id": self.chart.id,
"metrics": self.chart.params_dict["metrics"],
},
slice_=self.chart,
queries=[],
)
}
)
Expand All @@ -399,7 +410,11 @@ def test_raise_for_access__native_filter_datasource_not_associated(self):
"dashboardId": self.dash.id,
"native_filter_id": "NATIVE_FILTER-ABCDEFGH",
"type": "NATIVE_FILTER",
"slice_id": self.chart.id,
"metrics": self.chart.params_dict["metrics"],
},
slice_=self.chart,
queries=[],
)
}
)
Expand Down
76 changes: 76 additions & 0 deletions tests/unit_tests/security/manager_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import pytest
from pytest_mock import MockFixture

from superset.common.query_object import QueryObject
from superset.exceptions import SupersetSecurityException
from superset.extensions import appbuilder
from superset.security.manager import SupersetSecurityManager
Expand All @@ -31,6 +32,81 @@ def test_security_manager(app_context: None) -> None:
assert sm


def test_raise_for_access_guest_user(
mocker: MockFixture,
app_context: None,
) -> None:
"""
Test that guest user can't modify chart payload.
"""
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
stored_metrics = [
{
"aggregate": None,
"column": None,
"datasourceWarning": False,
"expressionType": "SQL",
"hasCustomLabel": False,
"label": "COUNT(*) + 1",
"optionName": "metric_ssa1gwimio_cxpyjc7vj3s",
"sqlExpression": "COUNT(*) + 1",
}
]
query_context.slice_.params_dict = {
"metrics": stored_metrics,
}

# normal request
query_context.form_data = {
"slice_id": 42,
"metrics": stored_metrics,
}
query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore
sm.raise_for_access(query_context=query_context)

# tampered requests
query_context.form_data = {
"slice_id": 43,
"metrics": stored_metrics,
}
query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore
with pytest.raises(SupersetSecurityException):
sm.raise_for_access(query_context=query_context)

tampered_metrics = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@betodealmeida Not a blocker, but I think it's a good practice to separate different test cases in different tests. That way, it's easier to identify which part of a feature is failing. In this case, we could have a test for a legit request and another for a tampered request.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@betodealmeida Not a blocker, but I think it's a good practice to separate different test cases in different tests. That way, it's easier to identify which part of a feature is failing. In this case, we could have a test for a legit request and another for a tampered request.

Yeah, good point.

{
"aggregate": None,
"column": None,
"datasourceWarning": False,
"expressionType": "SQL",
"hasCustomLabel": False,
"label": "COUNT(*) + 2",
"optionName": "metric_ssa1gwimio_cxpyjc7vj3s",
"sqlExpression": "COUNT(*) + 2",
}
]

query_context.form_data = {
"slice_id": 42,
"metrics": tampered_metrics,
}
with pytest.raises(SupersetSecurityException):
sm.raise_for_access(query_context=query_context)

query_context.form_data = {
"slice_id": 42,
"metrics": stored_metrics,
}
query_context.queries = [QueryObject(metrics=tampered_metrics)] # type: ignore
with pytest.raises(SupersetSecurityException):
sm.raise_for_access(query_context=query_context)


def test_raise_for_access_query_default_schema(
mocker: MockFixture,
app_context: None,
Expand Down
Loading