diff --git a/superset/charts/api.py b/superset/charts/api.py index 390892c8259..605bd2cf7da 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -21,7 +21,7 @@ from io import BytesIO from typing import Any, cast, Optional from zipfile import is_zipfile, ZipFile -from flask import current_app, redirect, request, Response, send_file, url_for +from flask import current_app, redirect, request, Response, url_for from flask_appbuilder.api import expose, protect, rison as parse_rison, safe from flask_appbuilder.hooks import before_request from flask_appbuilder.models.sqla.interface import SQLAInterface @@ -103,7 +103,7 @@ from superset.subjects.filters import ( from superset.tasks.thumbnails import cache_chart_thumbnail from superset.tasks.utils import get_current_user from superset.utils import json -from superset.utils.core import sanitize_cookie_token +from superset.utils.core import send_export_zip from superset.utils.screenshots import ( ChartScreenshot, DEFAULT_CHART_WINDOW_SIZE, @@ -1293,15 +1293,7 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi): return self.response_404() buf.seek(0) - response = send_file( - buf, - mimetype="application/zip", - as_attachment=True, - download_name=filename, - ) - if token := sanitize_cookie_token(request.args.get("token")): - response.set_cookie(token, "done", max_age=600) - return response + return send_export_zip(buf, filename) @expose("/favorite_status/", methods=("GET",)) @protect() diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index eb754ed2753..ab6236e2421 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -24,7 +24,7 @@ from typing import Any, Callable, cast from zipfile import is_zipfile, ZipFile import rison -from flask import current_app, g, redirect, request, Response, send_file, url_for +from flask import current_app, g, redirect, request, Response, url_for from flask_appbuilder import permission_name from flask_appbuilder.api import ( expose, @@ -162,7 +162,7 @@ from superset.tasks.thumbnails import ( ) from superset.tasks.utils import get_current_user from superset.utils import json -from superset.utils.core import parse_boolean_string, sanitize_cookie_token +from superset.utils.core import parse_boolean_string, send_export_zip from superset.utils.file import get_filename from superset.utils.pdf import build_pdf_from_screenshots from superset.utils.screenshots import ( @@ -1629,15 +1629,7 @@ class DashboardRestApi( return self.response_404() buf.seek(0) - response = send_file( - buf, - mimetype="application/zip", - as_attachment=True, - download_name=filename, - ) - if token := sanitize_cookie_token(request.args.get("token")): - response.set_cookie(token, "done", max_age=600) - return response + return send_export_zip(buf, filename) @expose("//export_as_example/", methods=("GET",)) @protect() @@ -1720,15 +1712,7 @@ class DashboardRestApi( filename = f"{safe_name}_example.zip" - response = send_file( - buf, - mimetype="application/zip", - as_attachment=True, - download_name=filename, - ) - if token := sanitize_cookie_token(request.args.get("token")): - response.set_cookie(token, "done", max_age=600) - return response + return send_export_zip(buf, filename) @expose("//export_xlsx/", methods=("POST",)) @protect() diff --git a/superset/databases/api.py b/superset/databases/api.py index c249fa82dd7..da34fc21572 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -31,7 +31,6 @@ from flask import ( render_template, request, Response, - send_file, ) from flask_appbuilder.api import expose, protect, rison as parse_rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface @@ -131,7 +130,7 @@ from superset.utils.core import ( error_msg_from_exception, get_username, parse_js_uri_path_item, - sanitize_cookie_token, + send_export_zip, ) from superset.utils.decorators import transaction from superset.utils.oauth2 import decode_oauth2_state @@ -1572,15 +1571,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi): return self.response_404() buf.seek(0) - response = send_file( - buf, - mimetype="application/zip", - as_attachment=True, - download_name=filename, - ) - if token := sanitize_cookie_token(request.args.get("token")): - response.set_cookie(token, "done", max_age=600) - return response + return send_export_zip(buf, filename) @expose("/import/", methods=("POST",)) @protect() diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 1fc7634b8b3..e2eddf0176f 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -23,7 +23,7 @@ from io import BytesIO from typing import Any, Callable from zipfile import is_zipfile, ZipFile -from flask import request, Response, send_file +from flask import request, Response from flask_appbuilder import permission_name from flask_appbuilder.api import expose, protect, rison as parse_rison, safe from flask_appbuilder.api.schemas import get_item_schema @@ -93,7 +93,7 @@ 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, sanitize_cookie_token +from superset.utils.core import parse_boolean_string, send_export_zip from superset.versioning.api_helpers import ( current_entity_etag_uuid, current_entity_version_info, @@ -811,15 +811,7 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi): return self.response_404() buf.seek(0) - response = send_file( - buf, - mimetype="application/zip", - as_attachment=True, - download_name=filename, - ) - if token := sanitize_cookie_token(request.args.get("token")): - response.set_cookie(token, "done", max_age=600) - return response + return send_export_zip(buf, filename) @expose("/duplicate", methods=("POST",)) @protect() diff --git a/superset/importexport/api.py b/superset/importexport/api.py index d65143f5b1d..066915f9411 100644 --- a/superset/importexport/api.py +++ b/superset/importexport/api.py @@ -18,7 +18,7 @@ from datetime import datetime from io import BytesIO from zipfile import is_zipfile, ZipFile -from flask import request, Response, send_file +from flask import request, Response from flask_appbuilder.api import expose, protect from superset.commands.export.assets import ExportAssetsCommand @@ -30,7 +30,7 @@ from superset.commands.importers.v1.assets import ImportAssetsCommand from superset.commands.importers.v1.utils import get_contents_from_bundle from superset.extensions import event_logger from superset.utils import json -from superset.utils.core import parse_boolean_string +from superset.utils.core import parse_boolean_string, send_export_zip from superset.views.base_api import BaseSupersetApi, requires_form_data, statsd_metrics @@ -84,13 +84,7 @@ class ImportExportRestApi(BaseSupersetApi): fp.write(file_content().encode()) buf.seek(0) - response = send_file( - buf, - mimetype="application/zip", - as_attachment=True, - download_name=filename, - ) - return response + return send_export_zip(buf, filename) @expose("/import/", methods=("POST",)) @protect() diff --git a/superset/queries/saved_queries/api.py b/superset/queries/saved_queries/api.py index 1021cf5f56d..971f2300c60 100644 --- a/superset/queries/saved_queries/api.py +++ b/superset/queries/saved_queries/api.py @@ -20,7 +20,7 @@ from io import BytesIO from typing import Any from zipfile import is_zipfile, ZipFile -from flask import g, request, Response, send_file +from flask import g, request, Response from flask_appbuilder.api import expose, protect, rison as parse_rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import ngettext @@ -55,7 +55,7 @@ from superset.queries.saved_queries.schemas import ( validate_label, ) from superset.utils import json -from superset.utils.core import sanitize_cookie_token +from superset.utils.core import send_export_zip from superset.views.base_api import ( BaseSupersetModelRestApi, RelatedFieldFilter, @@ -307,15 +307,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): return self.response_404() buf.seek(0) - response = send_file( - buf, - mimetype="application/zip", - as_attachment=True, - download_name=filename, - ) - if token := sanitize_cookie_token(request.args.get("token")): - response.set_cookie(token, "done", max_age=600) - return response + return send_export_zip(buf, filename) @expose("/import/", methods=("POST",)) @protect() diff --git a/superset/themes/api.py b/superset/themes/api.py index 4e26971f081..c02efadece4 100644 --- a/superset/themes/api.py +++ b/superset/themes/api.py @@ -20,7 +20,7 @@ from io import BytesIO from typing import Any from zipfile import ZipFile -from flask import current_app as app, request, Response, send_file +from flask import current_app as app, request, Response from flask_appbuilder.api import expose, protect, rison as parse_rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import ngettext @@ -55,7 +55,7 @@ from superset.themes.schemas import ( ThemePostSchema, ThemePutSchema, ) -from superset.utils.core import sanitize_cookie_token +from superset.utils.core import send_export_zip from superset.utils.decorators import transaction from superset.views.base_api import ( BaseSupersetModelRestApi, @@ -486,15 +486,7 @@ class ThemeRestApi(BaseSupersetModelRestApi): return self.response_404() buf.seek(0) - response = send_file( - buf, - mimetype="application/zip", - as_attachment=True, - download_name=filename, - ) - if token := sanitize_cookie_token(request.args.get("token")): - response.set_cookie(token, "done", max_age=600) - return response + return send_export_zip(buf, filename) @expose("/import/", methods=("POST",)) @protect() diff --git a/superset/utils/core.py b/superset/utils/core.py index 5c91a99233c..a2d6260cf26 100644 --- a/superset/utils/core.py +++ b/superset/utils/core.py @@ -69,7 +69,7 @@ import pandas as pd import sqlalchemy as sa from cryptography.hazmat.backends import default_backend from cryptography.x509 import Certificate, load_pem_x509_certificate -from flask import current_app as app, g, request +from flask import current_app as app, g, request, Response, send_file from flask_appbuilder.security.sqla.models import User from flask_babel import gettext as __ from flask_sqlalchemy import SQLAlchemy @@ -2230,6 +2230,39 @@ def create_zip(files: dict[str, Any]) -> BytesIO: return buf +def send_export_zip(buf: BytesIO, filename: str) -> Response: + """Build a non-cacheable ZIP attachment response for the export endpoints. + + Export bundles are generated per request from live metadata, so they must never + be cached. Flask applies ``SEND_FILE_MAX_AGE_DEFAULT`` (one year in Superset's + config) to every ``send_file`` response that does not opt out, which made + browsers and intermediate proxies serve stale export archives. Passing + ``max_age=0`` and marking the response ``no-store``/``no-cache`` keeps the + behavior of genuine static assets untouched while forcing exports to be fetched + fresh every time. + + The optional client-provided ``token`` query parameter is echoed back as a + cookie so the UI can detect that the download finished. + + :param buf: an in-memory ZIP archive, positioned at the start + :param filename: the download file name advertised to the client + :return: the response to return from the export endpoint + """ + response = send_file( + buf, + mimetype="application/zip", + as_attachment=True, + download_name=filename, + max_age=0, + ) + response.cache_control.no_store = True + response.cache_control.no_cache = True + response.cache_control.must_revalidate = True + if token := sanitize_cookie_token(request.args.get("token")): + response.set_cookie(token, "done", max_age=600) + return response + + def check_is_safe_zip(zip_file: ZipFile) -> None: """ Checks whether a ZIP file is safe, raises SupersetException if not. diff --git a/tests/unit_tests/utils/send_export_zip_tests.py b/tests/unit_tests/utils/send_export_zip_tests.py new file mode 100644 index 00000000000..3eb083e2d08 --- /dev/null +++ b/tests/unit_tests/utils/send_export_zip_tests.py @@ -0,0 +1,69 @@ +# 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 io import BytesIO + +from superset.app import SupersetApp +from superset.utils.core import send_export_zip + +ARCHIVE_NAME = "dashboard_export_20260101T000000.zip" + + +def _buf() -> BytesIO: + return BytesIO(b"PK\x05\x06" + b"\x00" * 18) + + +def test_export_zip_is_not_cacheable(app: SupersetApp) -> None: + """ + Export bundles are generated per request, so they must never be cached. + + Flask stamps SEND_FILE_MAX_AGE_DEFAULT -- one year in Superset's config -- on + every send_file response that does not opt out, which left browsers and proxies + serving stale archives (#41687). + """ + with app.test_request_context("/api/v1/dashboard/export/"): + response = send_export_zip(_buf(), ARCHIVE_NAME) + + assert response.cache_control.no_store + assert response.cache_control.no_cache + assert response.cache_control.must_revalidate + assert response.cache_control.max_age == 0 + assert "max-age=31536000" not in response.headers["Cache-Control"] + + +def test_export_zip_is_a_named_attachment(app: SupersetApp) -> None: + with app.test_request_context("/api/v1/dashboard/export/"): + response = send_export_zip(_buf(), ARCHIVE_NAME) + + assert response.mimetype == "application/zip" + assert ARCHIVE_NAME in response.headers["Content-Disposition"] + assert response.headers["Content-Disposition"].startswith("attachment") + + +def test_export_zip_echoes_the_download_token(app: SupersetApp) -> None: + with app.test_request_context("/api/v1/dashboard/export/?token=done_token"): + response = send_export_zip(_buf(), ARCHIVE_NAME) + + cookies = response.headers.getlist("Set-Cookie") + assert any("done_token=done" in cookie for cookie in cookies) + + +def test_export_zip_ignores_an_unsafe_download_token(app: SupersetApp) -> None: + with app.test_request_context("/api/v1/dashboard/export/?token=bad%0d%0atoken"): + response = send_export_zip(_buf(), ARCHIVE_NAME) + + assert not response.headers.getlist("Set-Cookie")