This is an automated email from the ASF dual-hosted git repository.

Antonio-RiveroMartnez 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 98fac9104fb fix(datasets): validate metric d3format (#45633)
98fac9104fb is described below

commit 98fac9104fbf6765526fc2e29da9305cfa355839
Author: Shaitan <[email protected]>
AuthorDate: Thu Oct 22 21:49:15 2026 +0100

    fix(datasets): validate metric d3format (#45633)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 superset/datasets/schemas.py                  | 17 ++++-
 superset/mcp_service/dataset/schemas.py       | 21 ++++++
 superset/translations/messages.pot            |  3 +
 superset/utils/number_format.py               | 94 +++++++++++++++++++++++++++
 tests/integration_tests/datasets/api_tests.py | 45 +++++++++++++
 tests/unit_tests/datasets/schema_tests.py     | 80 +++++++++++++++++++++++
 tests/unit_tests/utils/number_format_test.py  | 31 +++++++++
 7 files changed, 289 insertions(+), 2 deletions(-)

diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py
index 9001d02829b..163776b818a 100644
--- a/superset/datasets/schemas.py
+++ b/superset/datasets/schemas.py
@@ -36,6 +36,7 @@ from superset.constants import EPOCH_FORMATS
 from superset.exceptions import SupersetMarshmallowValidationError
 from superset.models.sql_types import parse_currency_string
 from superset.utils import json
+from superset.utils.number_format import is_valid_d3_format
 from superset.utils.schema import DiscardIsManagedExternallyMixin
 
 get_delete_ids_schema = {
@@ -80,6 +81,16 @@ def validate_python_date_format(dt_format: str) -> bool:
     return True
 
 
+def validate_d3format(value: str | None) -> None:
+    if not is_valid_d3_format(value):
+        raise ValidationError(
+            _(
+                "Invalid number format: must be a d3-format specifier or a "
+                "supported named format"
+            )
+        )
+
+
 class ResolveIsCertifiedMixin(Schema):
     """
     ``is_certified`` is derived from 
``certified_by``/``certification_details``.
@@ -165,7 +176,9 @@ class DatasetMetricsPutSchema(ResolveIsCertifiedMixin, 
Schema):
 
     metric_name = fields.String(required=True, validate=Length(1, 255))
     metric_type = fields.String(allow_none=True, validate=Length(1, 32))
-    d3format = fields.String(allow_none=True, validate=Length(1, 128))
+    d3format = fields.String(
+        allow_none=True, validate=[Length(1, 128), validate_d3format]
+    )
     currency = CurrencyField(DatasetMetricCurrencyPutSchema, allow_none=True)
     verbose_name = fields.String(allow_none=True, metadata={Length: (1, 1024)})
     warning_text = fields.String(allow_none=True)
@@ -421,7 +434,7 @@ class ImportV1MetricSchema(Schema):
     metric_type = fields.String(allow_none=True)
     expression = fields.String(required=True)
     description = fields.String(allow_none=True)
-    d3format = fields.String(allow_none=True)
+    d3format = fields.String(allow_none=True, validate=validate_d3format)
     currency = CurrencyField(ImportMetricCurrencySchema, allow_none=True)
     extra = fields.Dict(allow_none=True)
     warning_text = fields.String(allow_none=True)
diff --git a/superset/mcp_service/dataset/schemas.py 
b/superset/mcp_service/dataset/schemas.py
index 7ac3e6fd8d6..d94c9873b8e 100644
--- a/superset/mcp_service/dataset/schemas.py
+++ b/superset/mcp_service/dataset/schemas.py
@@ -72,6 +72,7 @@ from superset.mcp_service.utils.serialization import (
 )
 from superset.sql.parse import has_aggregate
 from superset.utils import json
+from superset.utils.number_format import is_valid_d3_format
 
 
 class DatasetFilter(ColumnOperator):
@@ -475,6 +476,16 @@ class CreateDatasetMetric(BaseModel):
             )
         return value
 
+    @field_validator("d3format")
+    @classmethod
+    def d3format_must_be_valid(cls, value: str | None) -> str | None:
+        if not is_valid_d3_format(value):
+            raise ValueError(
+                "Invalid number format: must be a d3-format specifier or a "
+                "supported named format"
+            )
+        return value
+
 
 class CreateDatasetCalculatedColumn(BaseModel):
     """Calculated column definition for dataset creation."""
@@ -718,6 +729,16 @@ class DatasetMetricProperties(OmittedMeansUnchanged):
         None, description="JSON-encoded string with extra metric metadata."
     )
 
+    @field_validator("d3format")
+    @classmethod
+    def d3format_must_be_valid(cls, value: str | None) -> str | None:
+        if not is_valid_d3_format(value):
+            raise ValueError(
+                "Invalid number format: must be a d3-format specifier or a "
+                "supported named format"
+            )
+        return value
+
     def updates(self) -> Dict[str, Any]:
         """Return only the metric properties explicitly provided by the caller.
 
diff --git a/superset/translations/messages.pot 
b/superset/translations/messages.pot
index fad4710ba14..da1b67008df 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -7868,6 +7868,9 @@ msgstr ""
 msgid "Invalid metric object: %(metric)s"
 msgstr ""
 
+msgid "Invalid number format: must be a d3-format specifier or a supported 
named format"
+msgstr ""
+
 #, python-format
 msgid "Invalid numpy function: %(operator)s"
 msgstr ""
diff --git a/superset/utils/number_format.py b/superset/utils/number_format.py
index 99fccfbefa2..b7050cdd83a 100644
--- a/superset/utils/number_format.py
+++ b/superset/utils/number_format.py
@@ -609,3 +609,97 @@ def raw_string(value: float) -> str:
     if isinstance(value, float) and value.is_integer():
         return str(int(value))
     return str(value)
+
+
+# ---------------------------------------------------------------------------
+# Server-side validation of a user-supplied ``d3format`` string.
+#
+# Distinct from ``D3_FORMAT_RE`` above (which parses the subset the 
report-table
+# path renders and rejects padding): this matches the full d3-format grammar 
for
+# *acceptance* and also allows the named formats the frontend registers, so the
+# dataset API accepts exactly what the UI can render and nothing else.
+# ---------------------------------------------------------------------------
+
+OVER_MAX_HIDDEN: str = "OVER_MAX_HIDDEN"
+
+# Full d3-format specifier, matched against the whole string:
+#   [[fill]align][sign][symbol][0][width][,][.precision][~][type]
+# A fill character is allowed only when followed by an align character; type is
+# case-sensitive. See https://github.com/d3/d3-format.
+D3_FORMAT_VALIDATION_RE: re.Pattern[str] = re.compile(
+    r"(?:.?[<>=^])?"  # [[fill]align]
+    r"[-+ (]?"  # [sign]
+    r"[$#]?"  # [symbol]
+    r"0?"  # [0] zero-pad flag
+    r"\d*"  # [width]
+    r",?"  # [,] group separator
+    r"(?:\.\d+)?"  # [.precision]
+    r"~?"  # [~] trim insignificant zeros
+    r"[efgrspbodxXcn%]?"  # [type]
+)
+
+# Named number formats (and deprecated/typo shims) the frontend accepts as
+# ``d3format`` values but that are not plain d3 specifiers. Kept in one 
constant
+# so the backend stays in sync with what the UI renders. Sources:
+#   - superset-frontend/packages/superset-ui-core/src/number-format/
+#     NumberFormats.ts (SMART_NUMBER, SMART_NUMBER_SIGNED, OVER_MAX_HIDDEN)
+#   - superset-frontend/src/setup/setupFormatters.ts (the registered named
+#     formatters -- already enumerated by UNSUPPORTED_FRONTEND_PRESETS, plus 
the
+#     throughput formatters -- and the "deprecated or common typo" shims)
+#   - superset-frontend/src/explore/controls.tsx (D3_FORMAT_OPTIONS)
+NAMED_NUMBER_FORMATS: frozenset[str] = UNSUPPORTED_FRONTEND_PRESETS | 
frozenset(
+    {
+        SMART_NUMBER,
+        SMART_NUMBER_SIGNED,
+        OVER_MAX_HIDDEN,
+        "NETWORK_THROUGHPUT",
+        "NETWORK_THROUGHPUT_FROM_BYTES",
+        # deprecated / common-typo shims (setupFormatters.ts)
+        ",0",
+        "null",
+        "%",
+        ".",
+        ",f",
+        ",r",
+        "0f",
+        ",#",
+        "$,f",
+        "0%",
+        "f",
+        ",.",
+        ".1%f",
+        "1%",
+        "3%",
+        ",%",
+        ".r",
+        "$,.0",
+        "$,.1",
+        ",0s",
+        "%%%",
+        ",0f",
+        "+,%",
+        "$f",
+        "+,",
+        ",2f",
+        ",g",
+        "int",
+        ".0%f",
+        "$,0",
+        "$,0f",
+        "$,.f",
+    }
+)
+
+
+def is_valid_d3_format(value: str | None) -> bool:
+    """Return whether ``value`` is an acceptable ``d3format`` string.
+
+    ``None`` and the empty string (unset) are accepted, as are the supported
+    named formats and any well-formed d3-format specifier. Length is enforced
+    separately by the schema.
+    """
+    if not value:
+        return True
+    if value in NAMED_NUMBER_FORMATS:
+        return True
+    return D3_FORMAT_VALIDATION_RE.fullmatch(value) is not None
diff --git a/tests/integration_tests/datasets/api_tests.py 
b/tests/integration_tests/datasets/api_tests.py
index 6a6ff7e8a23..4844193cb29 100644
--- a/tests/integration_tests/datasets/api_tests.py
+++ b/tests/integration_tests/datasets/api_tests.py
@@ -1477,6 +1477,51 @@ class TestDatasetApi(SupersetTestCase):
 
         self.items_to_delete = [dataset]
 
+    def test_update_dataset_metric_d3format_validation(self):
+        """
+        Dataset API: a metric d3format must be a valid number format
+        """
+        dataset = self.insert_default_dataset()
+        uri = f"api/v1/dataset/{dataset.id}"
+        self.login(ADMIN_USERNAME)
+        rv = self.get_assert_metric(uri, "get")
+        existing = json.loads(rv.data.decode("utf-8"))["result"]["metrics"]
+        strip_read_only_fields(existing)
+
+        base_metric = {
+            "description": None,
+            "expression": "COUNT(*)",
+            "metric_name": "fmt_metric",
+            "metric_type": None,
+            "verbose_name": None,
+            "warning_text": None,
+        }
+
+        # An invalid number format is rejected and nothing is stored.
+        # DatasetPutSchema reports schema errors as a SIP-40 422.
+        invalid = {**base_metric, "d3format": "<img src=x onerror=alert(1)>"}
+        rv = self.client.put(uri, json={"metrics": [*existing, invalid]})
+        assert rv.status_code == 422
+        assert (
+            db.session.query(SqlMetric)
+            .filter_by(table_id=dataset.id, metric_name="fmt_metric")
+            .one_or_none()
+            is None
+        )
+
+        # A valid specifier saves.
+        valid = {**base_metric, "d3format": ",.2f"}
+        rv = self.client.put(uri, json={"metrics": [*existing, valid]})
+        assert rv.status_code == 200
+        stored = (
+            db.session.query(SqlMetric)
+            .filter_by(table_id=dataset.id, metric_name="fmt_metric")
+            .one()
+        )
+        assert stored.d3format == ",.2f"
+
+        self.items_to_delete = [dataset]
+
     def test_update_dataset_delete_metric(self):
         """
         Dataset API: Test update dataset delete metric
diff --git a/tests/unit_tests/datasets/schema_tests.py 
b/tests/unit_tests/datasets/schema_tests.py
index 684c1ad765c..a2cf4b7962b 100644
--- a/tests/unit_tests/datasets/schema_tests.py
+++ b/tests/unit_tests/datasets/schema_tests.py
@@ -369,3 +369,83 @@ def test_dataset_put_schema_resolves_is_certified(
     result = DatasetColumnsPutSchema().load({"column_name": "a", **item})
 
     assert result == {"column_name": "a", **expected}
+
+
[email protected]("fmt", [None, ",.2f", ".3s", "SMART_NUMBER", 
"DURATION"])
+def test_metric_put_schema_accepts_valid_d3format(fmt: Any) -> None:
+    from superset.datasets.schemas import DatasetMetricsPutSchema
+
+    result = DatasetMetricsPutSchema().load(
+        {"metric_name": "m", "expression": "COUNT(*)", "d3format": fmt}
+    )
+    assert result["metric_name"] == "m"
+
+
[email protected](
+    "fmt",
+    [
+        "<script>alert(1)</script>",
+        "<img src=x onerror=alert(1)>",
+        "nulld2fxk)",
+        ",.2f<b>",
+        "SMART_NUMBER<",
+    ],
+)
+def test_metric_put_schema_rejects_invalid_d3format(fmt: str) -> None:
+    from superset.datasets.schemas import DatasetMetricsPutSchema
+
+    with pytest.raises(ValidationError) as exc:
+        DatasetMetricsPutSchema().load(
+            {"metric_name": "m", "expression": "COUNT(*)", "d3format": fmt}
+        )
+    assert "d3format" in exc.value.messages
+
+
+def test_metric_put_schema_rejects_over_max_length_d3format() -> None:
+    from superset.datasets.schemas import DatasetMetricsPutSchema
+
+    # A well-formed specifier that exceeds the 128-char column limit.
+    too_long = "," + "." + "1" * 130 + "f"
+    with pytest.raises(ValidationError) as exc:
+        DatasetMetricsPutSchema().load(
+            {"metric_name": "m", "expression": "COUNT(*)", "d3format": 
too_long}
+        )
+    assert "d3format" in exc.value.messages
+
+
+def test_dataset_put_schema_reports_offending_metric_index() -> None:
+    from superset.datasets.schemas import DatasetPutSchema
+    from superset.exceptions import SupersetMarshmallowValidationError
+
+    # DatasetPutSchema.handle_error wraps marshmallow errors in a SIP-40 error
+    # (HTTP 422); the nested payload still points at the offending metric 
index.
+    with pytest.raises(SupersetMarshmallowValidationError) as exc:
+        DatasetPutSchema().load(
+            {
+                "metrics": [
+                    {"metric_name": "ok", "expression": "COUNT(*)", 
"d3format": ",.2f"},
+                    {
+                        "metric_name": "bad",
+                        "expression": "COUNT(*)",
+                        "d3format": "<script>alert(1)</script>",
+                    },
+                ]
+            }
+        )
+    messages = exc.value.error.extra["messages"]
+    assert 1 in messages["metrics"]
+    assert "d3format" in messages["metrics"][1]
+
+
+def test_import_metric_schema_rejects_invalid_d3format() -> None:
+    from superset.datasets.schemas import ImportV1MetricSchema
+
+    with pytest.raises(ValidationError) as exc:
+        ImportV1MetricSchema().load(
+            {
+                "metric_name": "m",
+                "expression": "COUNT(*)",
+                "d3format": "<img src=x onerror=alert(1)>",
+            }
+        )
+    assert "d3format" in exc.value.messages
diff --git a/tests/unit_tests/utils/number_format_test.py 
b/tests/unit_tests/utils/number_format_test.py
index 30369e8ee9a..48985af2363 100644
--- a/tests/unit_tests/utils/number_format_test.py
+++ b/tests/unit_tests/utils/number_format_test.py
@@ -28,6 +28,8 @@ from superset.utils.number_format import (
     format_number_with_config,
     format_numeric,
     get_currency_locale,
+    is_valid_d3_format,
+    NAMED_NUMBER_FORMATS,
     resolve_auto_currency,
     resolve_symbol_position,
 )
@@ -586,3 +588,32 @@ def 
test_unsupported_d3_padding_is_explicitly_rejected(d3_format: str) -> None:
     with pytest.raises(ValueError, match="padding"):
         format_d3(d3_format, 1234.5)
     assert format_number_with_config(d3_format, None, 1234.5) == "1234.5"
+
+
[email protected](
+    "value",
+    [None, "", ",d", ",.2f", ".3s", "+,.1%", "$,.2f", "~g", "0>8d", "(,.2f", 
".4r"],
+)
+def test_is_valid_d3_format_accepts_valid_specifiers(value: str | None) -> 
None:
+    assert is_valid_d3_format(value) is True
+
+
[email protected]("value", sorted(NAMED_NUMBER_FORMATS))
+def test_is_valid_d3_format_accepts_named_formats(value: str) -> None:
+    assert is_valid_d3_format(value) is True
+
+
[email protected](
+    "value",
+    [
+        "<script>alert(1)</script>",
+        "<img src=x onerror=alert(1)>",
+        "nulld2fxk)",
+        ",.2f<b>",
+        "SMART_NUMBER<",
+        "not a format",
+        "abc",
+    ],
+)
+def test_is_valid_d3_format_rejects_invalid_values(value: str) -> None:
+    assert is_valid_d3_format(value) is False

Reply via email to