diff --git a/superset/charts/api.py b/superset/charts/api.py index 260ae4442c75c..601bb9f841a90 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -19,7 +19,7 @@ from datetime import datetime from io import BytesIO from typing import Any, Optional -from zipfile import ZipFile +from zipfile import is_zipfile, ZipFile from flask import g, redirect, request, Response, send_file, url_for from flask_appbuilder.api import expose, protect, rison, safe @@ -64,7 +64,10 @@ screenshot_query_schema, thumbnail_query_schema, ) -from superset.commands.importers.exceptions import NoValidFilesFoundError +from superset.commands.importers.exceptions import ( + IncorrectFormatError, + NoValidFilesFoundError, +) from superset.commands.importers.v1.utils import get_contents_from_bundle from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod from superset.extensions import event_logger @@ -871,6 +874,8 @@ def import_(self) -> Response: upload = request.files.get("formData") if not upload: return self.response_400() + if not is_zipfile(upload): + raise IncorrectFormatError("Not a ZIP file") with ZipFile(upload) as bundle: contents = get_contents_from_bundle(bundle) diff --git a/superset/databases/api.py b/superset/databases/api.py index 0de8bcf83e9ba..186ce93e06db0 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -20,7 +20,7 @@ from datetime import datetime from io import BytesIO from typing import Any, Dict, List, Optional -from zipfile import ZipFile +from zipfile import is_zipfile, ZipFile from flask import g, request, Response, send_file from flask_appbuilder.api import expose, protect, rison, safe @@ -29,7 +29,10 @@ from sqlalchemy.exc import NoSuchTableError, OperationalError, SQLAlchemyError from superset import app, event_logger -from superset.commands.importers.exceptions import NoValidFilesFoundError +from superset.commands.importers.exceptions import ( + IncorrectFormatError, + NoValidFilesFoundError, +) from superset.commands.importers.v1.utils import get_contents_from_bundle from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod from superset.databases.commands.create import CreateDatabaseCommand @@ -825,6 +828,8 @@ def import_(self) -> Response: upload = request.files.get("formData") if not upload: return self.response_400() + if not is_zipfile(upload): + raise IncorrectFormatError("Not a ZIP file") with ZipFile(upload) as bundle: contents = get_contents_from_bundle(bundle) diff --git a/superset/queries/saved_queries/api.py b/superset/queries/saved_queries/api.py index 04df2345c1c2f..19d4191f257b7 100644 --- a/superset/queries/saved_queries/api.py +++ b/superset/queries/saved_queries/api.py @@ -19,14 +19,17 @@ from datetime import datetime from io import BytesIO from typing import Any -from zipfile import ZipFile +from zipfile import is_zipfile, ZipFile from flask import g, request, Response, send_file from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import ngettext -from superset.commands.importers.exceptions import NoValidFilesFoundError +from superset.commands.importers.exceptions import ( + IncorrectFormatError, + NoValidFilesFoundError, +) from superset.commands.importers.v1.utils import get_contents_from_bundle from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod from superset.databases.filters import DatabaseFilter @@ -325,6 +328,8 @@ def import_(self) -> Response: upload = request.files.get("formData") if not upload: return self.response_400() + if not is_zipfile(upload): + raise IncorrectFormatError("Not a ZIP file") with ZipFile(upload) as bundle: contents = get_contents_from_bundle(bundle) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 4987aaf0e0e5c..43c4ac69ad359 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -25,6 +25,7 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.orm.session import Session +from superset import security_manager from superset.app import SupersetApp from superset.extensions import appbuilder from superset.initialization import SupersetAppInitializer @@ -61,6 +62,7 @@ def app() -> Iterator[SupersetApp]: app.config.from_object("superset.config") app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://" + app.config["WTF_CSRF_ENABLED"] = False app.config["TESTING"] = True # ``superset.extensions.appbuilder`` is a singleton, and won't rebuild the @@ -93,3 +95,21 @@ def app_context(app: SupersetApp) -> Iterator[None]: """ with app.app_context(): yield + + +@pytest.fixture +def full_api_access(mocker: MockFixture) -> Iterator[None]: + """ + Allow full access to the API. + + TODO (betodealmeida): we should replace this with user-fixtures, eg, ``admin`` or + ``gamma``, so that we have granular access to the APIs. + """ + mocker.patch( + "flask_appbuilder.security.decorators.verify_jwt_in_request", + return_value=True, + ) + mocker.patch.object(security_manager, "has_access", return_value=True) + mocker.patch.object(security_manager, "can_access_all_databases", return_value=True) + + yield diff --git a/tests/unit_tests/databases/api_test.py b/tests/unit_tests/databases/api_test.py new file mode 100644 index 0000000000000..2d20e5346f43e --- /dev/null +++ b/tests/unit_tests/databases/api_test.py @@ -0,0 +1,56 @@ +# 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. + +# pylint: disable=unused-argument, import-outside-toplevel, line-too-long + +from io import BytesIO +from typing import Any + +import pytest + + +def test_non_zip_import(client: Any, full_api_access: None) -> None: + """ + Test that non-ZIP imports are not allowed. + """ + buf = BytesIO(b"definitely_not_a_zip_file") + form_data = { + "formData": (buf, "evil.pdf"), + } + response = client.post( + "/api/v1/database/import/", + data=form_data, + content_type="multipart/form-data", + ) + assert response.status_code == 422 + assert response.json == { + "errors": [ + { + "message": "Not a ZIP file", + "error_type": "GENERIC_COMMAND_ERROR", + "level": "warning", + "extra": { + "issue_codes": [ + { + "code": 1010, + "message": "Issue 1010 - Superset encountered an error while running a command.", + } + ] + }, + } + ] + } diff --git a/tests/unit_tests/importexport/api_test.py b/tests/unit_tests/importexport/api_test.py index e5dee975d8cd8..1ef4309fc21b9 100644 --- a/tests/unit_tests/importexport/api_test.py +++ b/tests/unit_tests/importexport/api_test.py @@ -14,7 +14,8 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# pylint: disable=invalid-name, import-outside-toplevel + +# pylint: disable=invalid-name, import-outside-toplevel, unused-argument import json from io import BytesIO