Compare commits

...

2 Commits

Author SHA1 Message Date
Evan
10bcc8bab5 fix(viz): guard deck_slices against non-list values
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:09:48 -07:00
Claude Code
fb484cc932 feat(viz): bound list fields that fan out into extra queries
NVD3TimeSeriesViz.run_extra_queries iterates form_data['time_compare'] and
DeckGLMultiLayer.get_data iterates form_data['deck_slices'], each issuing one
or more database queries per entry, with no bound on list length. Add two
configurable limits, VIZ_TIME_COMPARE_MAX_LIST_SIZE and
VIZ_DECK_SLICES_MAX_LIST_SIZE (default 10, 0 disables), validated before the
loop; an over-limit list raises a clear QueryObjectValidationError.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:15:17 -07:00
3 changed files with 77 additions and 1 deletions

View File

@@ -168,6 +168,12 @@ NATIVE_FILTER_DEFAULT_ROW_LIMIT = 1000
# max rows retrieved by filter select auto complete
FILTER_SELECT_ROW_LIMIT = 10000
# Upper bound on the number of entries in user-supplied list fields that each
# fan out into one or more additional database queries within a single chart
# request, to limit query amplification. Set to 0 to disable a bound.
VIZ_TIME_COMPARE_MAX_LIST_SIZE = 10
VIZ_DECK_SLICES_MAX_LIST_SIZE = 10
# SupersetClient HTTP retry configuration
# Controls retry behavior for all HTTP requests made through SupersetClient
# This helps handle transient server errors (like 502 Bad Gateway) automatically

View File

@@ -1082,6 +1082,15 @@ class NVD3TimeSeriesViz(NVD3Viz):
if not isinstance(time_compare, list):
time_compare = [time_compare]
max_time_compare = current_app.config["VIZ_TIME_COMPARE_MAX_LIST_SIZE"]
if max_time_compare and len(time_compare) > max_time_compare:
raise QueryObjectValidationError(
_(
"Too many time comparisons requested; the maximum is %(limit)s.",
limit=max_time_compare,
)
)
for option in time_compare:
query_object = self.query_obj()
try:
@@ -1664,7 +1673,17 @@ class DeckGLMultiLayer(BaseViz):
from superset import db
from superset.models.slice import Slice
slice_ids = self.form_data.get("deck_slices")
slice_ids = self.form_data.get("deck_slices") or []
if not isinstance(slice_ids, list):
slice_ids = []
max_deck_slices = current_app.config["VIZ_DECK_SLICES_MAX_LIST_SIZE"]
if max_deck_slices and len(slice_ids) > max_deck_slices:
raise QueryObjectValidationError(
_(
"Too many layers requested; the maximum is %(limit)s.",
limit=max_deck_slices,
)
)
slices = db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
features: dict[str, list[Any]] = {}

View File

@@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from typing import Any
from unittest.mock import patch
@@ -91,6 +92,56 @@ def test_get_df_payload_captures_generic_exception_as_viz_get_df_error() -> None
assert obj.errors[0]["message"] == "boom"
def _datasource() -> SqlaTable:
database = Database(database_name="d", sqlalchemy_uri="sqlite://")
return SqlaTable(
table_name="t",
columns=[],
metrics=[],
main_dttm_col=None,
database=database,
)
def test_run_extra_queries_rejects_too_many_time_compares() -> None:
obj = viz.NVD3TimeSeriesViz(
datasource=_datasource(),
form_data={
"viz_type": "line",
"time_compare": [f"{i} days ago" for i in range(11)],
},
force=True,
)
with pytest.raises(QueryObjectValidationError, match="Too many time comparisons"):
obj.run_extra_queries()
def test_run_extra_queries_allows_time_compare_within_limit() -> None:
obj = viz.NVD3TimeSeriesViz(
datasource=_datasource(),
form_data={"viz_type": "line", "time_compare": ["1 day ago"]},
force=True,
)
query_obj = {"from_dttm": datetime(2021, 1, 2), "to_dttm": datetime(2021, 1, 3)}
with (
patch.object(viz.NVD3TimeSeriesViz, "query_obj", return_value=query_obj),
patch.object(
viz.NVD3TimeSeriesViz, "get_df_payload", return_value={"df": None}
),
):
obj.run_extra_queries() # must not raise the limit error
def test_deck_multi_rejects_too_many_slices() -> None:
obj = viz.DeckGLMultiLayer(
datasource=_datasource(),
form_data={"viz_type": "deck_multi", "deck_slices": list(range(11))},
force=True,
)
with pytest.raises(QueryObjectValidationError, match="Too many layers"):
obj.get_data(None)
def test_get_df_payload_captures_query_object_validation_error() -> None:
"""
``QueryObjectValidationError`` is reported as ``VIZ_GET_DF_ERROR``.