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

eschutho 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 5cfcee95b4e fix(import): return 400 instead of 500 on malformed JSON 
in import form fields (#45579)
5cfcee95b4e is described below

commit 5cfcee95b4e06dacb1f23d489d841c29c8893bce
Author: Elizabeth Thompson <[email protected]>
AuthorDate: Thu Oct 22 14:59:27 2026 -0700

    fix(import): return 400 instead of 500 on malformed JSON in import form 
fields (#45579)
    
    Co-authored-by: Claude Sonnet 5.5 <[email protected]>
---
 superset/charts/api.py                             | 25 +++-------
 superset/dashboards/api.py                         | 26 +++--------
 superset/databases/api.py                          | 32 +++----------
 superset/datasets/api.py                           | 26 +++--------
 superset/importexport/api.py                       | 38 +++++----------
 superset/queries/saved_queries/api.py              | 26 +++--------
 superset/views/base_api.py                         | 24 ++++++++++
 tests/unit_tests/databases/api_test.py             | 44 ++++++++++++++++++
 tests/unit_tests/importexport/api_test.py          | 30 ++++++++++++
 .../unit_tests/views/test_parse_json_form_field.py | 54 ++++++++++++++++++++++
 10 files changed, 195 insertions(+), 130 deletions(-)

diff --git a/superset/charts/api.py b/superset/charts/api.py
index e2b3488e04c..fd83e07cc93 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -150,6 +150,7 @@ from superset.versioning.etag import set_version_etag
 from superset.versioning.schemas import VersionListItemSchema
 from superset.views.base_api import (
     BaseSupersetModelRestApi,
+    parse_json_form_field,
     RelatedFieldFilter,
     requires_form_data,
     requires_json,
@@ -1754,26 +1755,12 @@ class ChartRestApi(SoftDeleteApiMixin, 
BaseSupersetModelRestApi):
         if not contents:
             raise NoValidFilesFoundError()
 
-        passwords = (
-            json.loads(request.form["passwords"])
-            if "passwords" in request.form
-            else None
-        )
+        passwords = parse_json_form_field("passwords")
         overwrite = request.form.get("overwrite") == "true"
-        ssh_tunnel_passwords = (
-            json.loads(request.form["ssh_tunnel_passwords"])
-            if "ssh_tunnel_passwords" in request.form
-            else None
-        )
-        ssh_tunnel_private_keys = (
-            json.loads(request.form["ssh_tunnel_private_keys"])
-            if "ssh_tunnel_private_keys" in request.form
-            else None
-        )
-        ssh_tunnel_priv_key_passwords = (
-            json.loads(request.form["ssh_tunnel_private_key_passwords"])
-            if "ssh_tunnel_private_key_passwords" in request.form
-            else None
+        ssh_tunnel_passwords = parse_json_form_field("ssh_tunnel_passwords")
+        ssh_tunnel_private_keys = 
parse_json_form_field("ssh_tunnel_private_keys")
+        ssh_tunnel_priv_key_passwords = parse_json_form_field(
+            "ssh_tunnel_private_key_passwords"
         )
 
         command = ImportChartsCommand(
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 1860d8d4682..bba2857eb92 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -210,7 +210,6 @@ from superset.tasks.thumbnails import (
     cache_dashboard_thumbnail,
 )
 from superset.tasks.utils import get_current_user
-from superset.utils import json
 from superset.utils.core import (
     get_user_id,
     parse_boolean_string,
@@ -237,6 +236,7 @@ from superset.versioning.etag import set_version_etag
 from superset.versioning.schemas import VersionListItemSchema
 from superset.views.base_api import (
     BaseSupersetModelRestApi,
+    parse_json_form_field,
     RelatedFieldFilter,
     requires_form_data,
     requires_json,
@@ -3185,28 +3185,14 @@ class DashboardRestApi(
         if not contents:
             raise NoValidFilesFoundError()
 
-        passwords = (
-            json.loads(request.form["passwords"])
-            if "passwords" in request.form
-            else None
-        )
+        passwords = parse_json_form_field("passwords")
         overwrite = request.form.get("overwrite") == "true"
         overwrite_all = parse_boolean_string(request.form.get("overwrite_all", 
"false"))
 
-        ssh_tunnel_passwords = (
-            json.loads(request.form["ssh_tunnel_passwords"])
-            if "ssh_tunnel_passwords" in request.form
-            else None
-        )
-        ssh_tunnel_private_keys = (
-            json.loads(request.form["ssh_tunnel_private_keys"])
-            if "ssh_tunnel_private_keys" in request.form
-            else None
-        )
-        ssh_tunnel_priv_key_passwords = (
-            json.loads(request.form["ssh_tunnel_private_key_passwords"])
-            if "ssh_tunnel_private_key_passwords" in request.form
-            else None
+        ssh_tunnel_passwords = parse_json_form_field("ssh_tunnel_passwords")
+        ssh_tunnel_private_keys = 
parse_json_form_field("ssh_tunnel_private_keys")
+        ssh_tunnel_priv_key_passwords = parse_json_form_field(
+            "ssh_tunnel_private_key_passwords"
         )
 
         command = ImportDashboardsCommand(
diff --git a/superset/databases/api.py b/superset/databases/api.py
index a1ac73a8514..d38f44c497b 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -128,7 +128,6 @@ from superset.extensions import security_manager
 from superset.models.core import Database
 from superset.sql.parse import Partition, Table
 from superset.superset_typing import FlaskResponse
-from superset.utils import json
 from superset.utils.core import (
     error_msg_from_exception,
     get_username,
@@ -141,6 +140,7 @@ from superset.utils.oauth2 import decode_oauth2_state
 from superset.utils.ssh_tunnel import mask_password_info
 from superset.views.base_api import (
     BaseSupersetModelRestApi,
+    parse_json_form_field,
     RelatedFieldFilter,
     requires_form_data,
     requires_json,
@@ -1710,32 +1710,14 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
         if not contents:
             raise NoValidFilesFoundError()
 
-        passwords = (
-            json.loads(request.form["passwords"])
-            if "passwords" in request.form
-            else None
-        )
+        passwords = parse_json_form_field("passwords")
         overwrite = request.form.get("overwrite") == "true"
-        ssh_tunnel_passwords = (
-            json.loads(request.form["ssh_tunnel_passwords"])
-            if "ssh_tunnel_passwords" in request.form
-            else None
-        )
-        ssh_tunnel_private_keys = (
-            json.loads(request.form["ssh_tunnel_private_keys"])
-            if "ssh_tunnel_private_keys" in request.form
-            else None
-        )
-        ssh_tunnel_priv_key_passwords = (
-            json.loads(request.form["ssh_tunnel_private_key_passwords"])
-            if "ssh_tunnel_private_key_passwords" in request.form
-            else None
-        )
-        encrypted_extra_secrets = (
-            json.loads(request.form["encrypted_extra_secrets"])
-            if "encrypted_extra_secrets" in request.form
-            else None
+        ssh_tunnel_passwords = parse_json_form_field("ssh_tunnel_passwords")
+        ssh_tunnel_private_keys = 
parse_json_form_field("ssh_tunnel_private_keys")
+        ssh_tunnel_priv_key_passwords = parse_json_form_field(
+            "ssh_tunnel_private_key_passwords"
         )
+        encrypted_extra_secrets = 
parse_json_form_field("encrypted_extra_secrets")
 
         command = ImportDatabasesCommand(
             contents,
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index f41fce02140..4fb8c333ca4 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -110,7 +110,6 @@ from superset.exceptions import (
 )
 from superset.jinja_context import BaseTemplateProcessor, 
get_template_processor
 from superset.subjects.filters import FilterRelatedSubjects, 
subject_type_filter
-from superset.utils import json
 from superset.utils.core import (
     parse_boolean_string,
     send_export_zip,
@@ -137,6 +136,7 @@ from superset.versioning.schemas import 
VersionListItemSchema
 from superset.views.base import DatasourceFilter
 from superset.views.base_api import (
     BaseSupersetModelRestApi,
+    parse_json_form_field,
     RelatedFieldFilter,
     requires_form_data,
     requires_json,
@@ -1754,28 +1754,14 @@ class DatasetRestApi(SoftDeleteApiMixin, 
BaseSupersetModelRestApi):
         if not contents:
             raise NoValidFilesFoundError()
 
-        passwords = (
-            json.loads(request.form["passwords"])
-            if "passwords" in request.form
-            else None
-        )
+        passwords = parse_json_form_field("passwords")
         overwrite = request.form.get("overwrite") == "true"
         sync_columns = request.form.get("sync_columns") == "true"
         sync_metrics = request.form.get("sync_metrics") == "true"
-        ssh_tunnel_passwords = (
-            json.loads(request.form["ssh_tunnel_passwords"])
-            if "ssh_tunnel_passwords" in request.form
-            else None
-        )
-        ssh_tunnel_private_keys = (
-            json.loads(request.form["ssh_tunnel_private_keys"])
-            if "ssh_tunnel_private_keys" in request.form
-            else None
-        )
-        ssh_tunnel_priv_key_passwords = (
-            json.loads(request.form["ssh_tunnel_private_key_passwords"])
-            if "ssh_tunnel_private_key_passwords" in request.form
-            else None
+        ssh_tunnel_passwords = parse_json_form_field("ssh_tunnel_passwords")
+        ssh_tunnel_private_keys = 
parse_json_form_field("ssh_tunnel_private_keys")
+        ssh_tunnel_priv_key_passwords = parse_json_form_field(
+            "ssh_tunnel_private_key_passwords"
         )
 
         command = ImportDatasetsCommand(
diff --git a/superset/importexport/api.py b/superset/importexport/api.py
index 3f912f5cd3a..15a6e27fce4 100644
--- a/superset/importexport/api.py
+++ b/superset/importexport/api.py
@@ -32,13 +32,17 @@ from superset.commands.importers.v1.utils import (
     open_import_bundle,
 )
 from superset.extensions import event_logger
-from superset.utils import json
 from superset.utils.core import (
     parse_boolean_string,
     send_export_zip,
     write_zip_entry,
 )
-from superset.views.base_api import BaseSupersetApi, requires_form_data, 
statsd_metrics
+from superset.views.base_api import (
+    BaseSupersetApi,
+    parse_json_form_field,
+    requires_form_data,
+    statsd_metrics,
+)
 
 
 class ImportExportRestApi(BaseSupersetApi):
@@ -199,31 +203,13 @@ class ImportExportRestApi(BaseSupersetApi):
         # endpoint always overwrote existing assets.
         overwrite = parse_boolean_string(request.form.get("overwrite", "true"))
 
-        passwords = (
-            json.loads(request.form["passwords"])
-            if "passwords" in request.form
-            else None
-        )
-        ssh_tunnel_passwords = (
-            json.loads(request.form["ssh_tunnel_passwords"])
-            if "ssh_tunnel_passwords" in request.form
-            else None
-        )
-        ssh_tunnel_private_keys = (
-            json.loads(request.form["ssh_tunnel_private_keys"])
-            if "ssh_tunnel_private_keys" in request.form
-            else None
-        )
-        ssh_tunnel_priv_key_passwords = (
-            json.loads(request.form["ssh_tunnel_private_key_passwords"])
-            if "ssh_tunnel_private_key_passwords" in request.form
-            else None
-        )
-        encrypted_extra_secrets = (
-            json.loads(request.form["encrypted_extra_secrets"])
-            if "encrypted_extra_secrets" in request.form
-            else None
+        passwords = parse_json_form_field("passwords")
+        ssh_tunnel_passwords = parse_json_form_field("ssh_tunnel_passwords")
+        ssh_tunnel_private_keys = 
parse_json_form_field("ssh_tunnel_private_keys")
+        ssh_tunnel_priv_key_passwords = parse_json_form_field(
+            "ssh_tunnel_private_key_passwords"
         )
+        encrypted_extra_secrets = 
parse_json_form_field("encrypted_extra_secrets")
 
         command = ImportAssetsCommand(
             contents,
diff --git a/superset/queries/saved_queries/api.py 
b/superset/queries/saved_queries/api.py
index 1662a3f97d0..37d794d6009 100644
--- a/superset/queries/saved_queries/api.py
+++ b/superset/queries/saved_queries/api.py
@@ -57,10 +57,10 @@ from superset.queries.saved_queries.schemas import (
     openapi_spec_methods_override,
     validate_label,
 )
-from superset.utils import json
 from superset.utils.core import send_export_zip, write_zip_entry
 from superset.views.base_api import (
     BaseSupersetModelRestApi,
+    parse_json_form_field,
     RelatedFieldFilter,
     requires_form_data,
     statsd_metrics,
@@ -402,26 +402,12 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
         if not contents:
             raise NoValidFilesFoundError()
 
-        passwords = (
-            json.loads(request.form["passwords"])
-            if "passwords" in request.form
-            else None
-        )
+        passwords = parse_json_form_field("passwords")
         overwrite = request.form.get("overwrite") == "true"
-        ssh_tunnel_passwords = (
-            json.loads(request.form["ssh_tunnel_passwords"])
-            if "ssh_tunnel_passwords" in request.form
-            else None
-        )
-        ssh_tunnel_private_keys = (
-            json.loads(request.form["ssh_tunnel_private_keys"])
-            if "ssh_tunnel_private_keys" in request.form
-            else None
-        )
-        ssh_tunnel_priv_key_passwords = (
-            json.loads(request.form["ssh_tunnel_private_key_passwords"])
-            if "ssh_tunnel_private_key_passwords" in request.form
-            else None
+        ssh_tunnel_passwords = parse_json_form_field("ssh_tunnel_passwords")
+        ssh_tunnel_private_keys = 
parse_json_form_field("ssh_tunnel_private_keys")
+        ssh_tunnel_priv_key_passwords = parse_json_form_field(
+            "ssh_tunnel_private_key_passwords"
         )
 
         command = ImportSavedQueriesCommand(
diff --git a/superset/views/base_api.py b/superset/views/base_api.py
index 8c4398725e7..aca0caee27b 100644
--- a/superset/views/base_api.py
+++ b/superset/views/base_api.py
@@ -50,6 +50,7 @@ from superset.models.slice import Slice
 from superset.schemas import error_payload_content
 from superset.sql_lab import Query as SqllabQuery
 from superset.superset_typing import FlaskResponse
+from superset.utils import json
 from superset.utils.core import get_user_id, time_function
 from superset.views.error_handling import handle_api_exception, 
json_error_response
 
@@ -191,6 +192,29 @@ def requires_form_data(f: Callable[..., Any]) -> 
Callable[..., Any]:
     return functools.update_wrapper(wraps, f)
 
 
+def parse_json_form_field(name: str) -> dict[str, Any] | None:
+    """
+    Parse an optional JSON object from a multipart form field.
+
+    :param name: the form field name
+    :returns: the parsed object, or None if the field is absent
+    :raises InvalidPayloadFormatError: if the value is not a valid JSON object
+    """
+    if name not in request.form:
+        return None
+    try:
+        value = json.loads(request.form[name])
+    except json.JSONDecodeError as ex:
+        raise InvalidPayloadFormatError(
+            message=f"Form field '{name}' is not valid JSON"
+        ) from ex
+    if not isinstance(value, dict):
+        raise InvalidPayloadFormatError(
+            message=f"Form field '{name}' must be a JSON object"
+        )
+    return value
+
+
 @overload
 def statsd_metrics(
     f: Callable[..., Any],
diff --git a/tests/unit_tests/databases/api_test.py 
b/tests/unit_tests/databases/api_test.py
index efc371aa34d..119474e69e6 100644
--- a/tests/unit_tests/databases/api_test.py
+++ b/tests/unit_tests/databases/api_test.py
@@ -709,6 +709,50 @@ def test_import_with_encrypted_extra_secrets(
     )
 
 
[email protected](
+    "field, value, message",
+    [
+        ("passwords", "{not json", "Form field 'passwords' is not valid JSON"),
+        ("passwords", "123", "Form field 'passwords' must be a JSON object"),
+        (
+            "encrypted_extra_secrets",
+            "{not json",
+            "Form field 'encrypted_extra_secrets' is not valid JSON",
+        ),
+    ],
+)
+def test_import_invalid_json_form_field(
+    mocker: MockerFixture,
+    client: Any,
+    full_api_access: None,
+    field: str,
+    value: str,
+    message: str,
+) -> None:
+    """
+    Test that a malformed JSON form field returns a 400 instead of a 500.
+    """
+    mocker.patch("superset.databases.api.is_zipfile", return_value=True)
+    mocker.patch("superset.databases.api.open_import_bundle")
+    mocker.patch(
+        "superset.databases.api.get_contents_from_bundle",
+        return_value={"metadata.yaml": "version: 1.0.0\n"},
+    )
+    command = mocker.patch("superset.databases.api.ImportDatabasesCommand")
+
+    form_data = {"formData": (BytesIO(b"test"), "test.zip"), field: value}
+    response = client.post(
+        "/api/v1/database/import/",
+        data=form_data,
+        content_type="multipart/form-data",
+    )
+
+    assert response.status_code == 400
+    assert response.json["errors"][0]["message"] == message
+    assert response.json["errors"][0]["error_type"] == 
"INVALID_PAYLOAD_FORMAT_ERROR"
+    command.assert_not_called()
+
+
 def test_non_zip_import(client: Any, full_api_access: None) -> None:
     """
     Test that non-ZIP imports are not allowed.
diff --git a/tests/unit_tests/importexport/api_test.py 
b/tests/unit_tests/importexport/api_test.py
index 4855c2fdfa4..ebf249716a8 100644
--- a/tests/unit_tests/importexport/api_test.py
+++ b/tests/unit_tests/importexport/api_test.py
@@ -420,6 +420,36 @@ def test_import_assets_incorrect_form_data(
     assert response.json == {"message": "Arguments are not correct"}
 
 
+def test_import_assets_malformed_json_form_field(
+    mocker: MockerFixture,
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """
+    Test that a malformed JSON form field returns a 400 instead of a 500.
+    """
+    ImportAssetsCommand = 
mocker.patch("superset.importexport.api.ImportAssetsCommand")  # noqa: N806
+
+    buf = BytesIO()
+    with ZipFile(buf, "w") as bundle:
+        with bundle.open("assets_export/metadata.yaml", "w") as fp:
+            fp.write(b"version: 1.0.0\ntype: assets\n")
+    buf.seek(0)
+
+    form_data = {
+        "bundle": (buf, "assets_export.zip"),
+        "encrypted_extra_secrets": "{not json",
+    }
+    response = client.post(
+        "/api/v1/assets/import/", data=form_data, 
content_type="multipart/form-data"
+    )
+    assert response.status_code == 400
+    assert response.json["errors"][0]["message"] == (
+        "Form field 'encrypted_extra_secrets' is not valid JSON"
+    )
+    ImportAssetsCommand.assert_not_called()
+
+
 def test_import_assets_no_contents(
     mocker: MockerFixture,
     client: Any,
diff --git a/tests/unit_tests/views/test_parse_json_form_field.py 
b/tests/unit_tests/views/test_parse_json_form_field.py
new file mode 100644
index 00000000000..bacff693657
--- /dev/null
+++ b/tests/unit_tests/views/test_parse_json_form_field.py
@@ -0,0 +1,54 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from typing import TYPE_CHECKING
+
+import pytest
+
+from superset.exceptions import InvalidPayloadFormatError
+from superset.views.base_api import parse_json_form_field
+
+if TYPE_CHECKING:
+    from superset.app import SupersetApp
+
+
+def test_parse_json_form_field_absent(app: "SupersetApp") -> None:
+    with app.test_request_context(method="POST", data={"other": "{}"}):
+        assert parse_json_form_field("passwords") is None
+
+
+def test_parse_json_form_field_valid(app: "SupersetApp") -> None:
+    with app.test_request_context(
+        method="POST", data={"passwords": '{"databases/db.yaml": "secret"}'}
+    ):
+        assert parse_json_form_field("passwords") == {"databases/db.yaml": 
"secret"}
+
+
+def test_parse_json_form_field_malformed(app: "SupersetApp") -> None:
+    with app.test_request_context(method="POST", data={"passwords": "{not 
json"}):
+        with pytest.raises(InvalidPayloadFormatError) as excinfo:
+            parse_json_form_field("passwords")
+    assert excinfo.value.status == 400
+    assert excinfo.value.error.message == "Form field 'passwords' is not valid 
JSON"
+
+
[email protected]("value", ["[]", "[1]", "123", '"secret"', "null"])
+def test_parse_json_form_field_not_object(app: "SupersetApp", value: str) -> 
None:
+    with app.test_request_context(method="POST", data={"passwords": value}):
+        with pytest.raises(InvalidPayloadFormatError) as excinfo:
+            parse_json_form_field("passwords")
+    assert excinfo.value.status == 400
+    assert excinfo.value.error.message == "Form field 'passwords' must be a 
JSON object"

Reply via email to