You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/06/24 18:03:53 UTC
(superset) branch master updated: fix(export): sanitize control characters in titles to prevent export failures (#39294)
This is an automated email from the ASF dual-hosted git repository.
rusackas 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 09c7ba14df4 fix(export): sanitize control characters in titles to prevent export failures (#39294)
09c7ba14df4 is described below
commit 09c7ba14df46f6332ccb777e2abfaf0aa73b46ac
Author: abhyudaytomar <68...@users.noreply.github.com>
AuthorDate: Wed Jun 24 23:33:46 2026 +0530
fix(export): sanitize control characters in titles to prevent export failures (#39294)
Co-authored-by: Abhyuday Tomar <ab...@exotel.com>
Co-authored-by: Evan <ev...@preset.io>
Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
.../src/dashboard/containers/DashboardPage.tsx | 3 +-
.../components/ExploreViewContainer/index.tsx | 3 +-
.../src/utils/sanitizeDocumentTitle.test.ts | 35 ++++++++++++++++++++++
.../src/utils/sanitizeDocumentTitle.ts | 27 +++++++++++++++++
superset/commands/report/execute.py | 11 +++----
superset/utils/file.py | 13 ++++++++
tests/unit_tests/utils/test_file.py | 22 +++++++++++++-
7 files changed, 106 insertions(+), 8 deletions(-)
diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
index 12a7716ab69..deec545931a 100644
--- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx
+++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
@@ -42,6 +42,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
+import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
import {
@@ -337,7 +338,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
// Update document title when dashboard title changes
useEffect(() => {
if (pageTitle) {
- document.title = pageTitle;
+ document.title = sanitizeDocumentTitle(pageTitle);
}
}, [pageTitle]);
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
index baa5b947314..49eb6e6e74c 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
@@ -66,6 +66,7 @@ import {
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
} from 'src/logger/LogUtils';
import { getUrlParam } from 'src/utils/urlUtils';
+import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
import cx from 'classnames';
import * as chartActions from 'src/components/Chart/chartAction';
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
@@ -397,7 +398,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
// Update document title when slice name changes
useEffect(() => {
if (props.sliceName) {
- document.title = props.sliceName;
+ document.title = sanitizeDocumentTitle(props.sliceName);
}
}, [props.sliceName]);
diff --git a/superset-frontend/src/utils/sanitizeDocumentTitle.test.ts b/superset-frontend/src/utils/sanitizeDocumentTitle.test.ts
new file mode 100644
index 00000000000..4c39918ba8a
--- /dev/null
+++ b/superset-frontend/src/utils/sanitizeDocumentTitle.test.ts
@@ -0,0 +1,35 @@
+/**
+ * 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.
+ */
+import { sanitizeDocumentTitle } from './sanitizeDocumentTitle';
+
+test('removes all C0 control characters including tab/LF/CR', () => {
+ expect(sanitizeDocumentTitle('a\x08b')).toBe('ab');
+ expect(sanitizeDocumentTitle('x\x09y')).toBe('xy');
+ expect(sanitizeDocumentTitle('x\ny')).toBe('xy');
+ expect(sanitizeDocumentTitle('x\ry')).toBe('xy');
+});
+
+test('removes DEL and C1 controls', () => {
+ expect(sanitizeDocumentTitle('a\x7Fb')).toBe('ab');
+ expect(sanitizeDocumentTitle('a\x9Fb')).toBe('ab');
+});
+
+test('leaves normal text unchanged', () => {
+ expect(sanitizeDocumentTitle('Dashboard 你好')).toBe('Dashboard 你好');
+});
diff --git a/superset-frontend/src/utils/sanitizeDocumentTitle.ts b/superset-frontend/src/utils/sanitizeDocumentTitle.ts
new file mode 100644
index 00000000000..742d13068b6
--- /dev/null
+++ b/superset-frontend/src/utils/sanitizeDocumentTitle.ts
@@ -0,0 +1,27 @@
+/**
+ * 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.
+ */
+
+/**
+ * Strip all C0/C1 control characters (U+0000–U+001F, U+007F–U+009F).
+ * Headless browsers (Playwright/Chromium) can hang or crash when document.title
+ * contains characters such as U+0008 (backspace).
+ */
+export function sanitizeDocumentTitle(title: string): string {
+ return title.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
+}
diff --git a/superset/commands/report/execute.py b/superset/commands/report/execute.py
index bdde2e34fcc..676f5776ad9 100644
--- a/superset/commands/report/execute.py
+++ b/superset/commands/report/execute.py
@@ -77,6 +77,7 @@ from superset.utils import json
from superset.utils.core import HeaderDataType, override_user, recipients_string_to_list
from superset.utils.csv import get_chart_csv_data, get_chart_dataframe
from superset.utils.decorators import logs_context, transaction
+from superset.utils.file import sanitize_title
from superset.utils.pdf import build_pdf_from_screenshots
from superset.utils.screenshots import ChartScreenshot, DashboardScreenshot
from superset.utils.slack import get_channels_with_search, SlackChannelTypes
@@ -701,7 +702,7 @@ class BaseReportState:
error_text = "Unexpected missing csv file"
if error_text:
return NotificationContent(
- name=self._report_schedule.name,
+ name=sanitize_title(self._report_schedule.name),
text=error_text,
header_data=header_data,
url=url,
@@ -714,15 +715,15 @@ class BaseReportState:
embedded_data = self._get_embedded_data()
if self._report_schedule.email_subject:
- name = self._report_schedule.email_subject
+ name = sanitize_title(self._report_schedule.email_subject)
else:
if self._report_schedule.chart:
- name = (
+ name = sanitize_title(
f"{self._report_schedule.name}: "
f"{self._report_schedule.chart.slice_name}"
)
else:
- name = (
+ name = sanitize_title(
f"{self._report_schedule.name}: "
f"{self._report_schedule.dashboard.dashboard_title}"
)
@@ -821,7 +822,7 @@ class BaseReportState:
self._execution_id,
)
notification_content = NotificationContent(
- name=name, text=message, header_data=header_data, url=url
+ name=sanitize_title(name), text=message, header_data=header_data, url=url
)
# filter recipients to recipients who are also owners
diff --git a/superset/utils/file.py b/superset/utils/file.py
index f1bdac897ea..34bf10f52b0 100644
--- a/superset/utils/file.py
+++ b/superset/utils/file.py
@@ -14,10 +14,23 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+import re
+
from werkzeug.utils import secure_filename
+# All C0 (U+0000–U+001F) and C1 (U+007F–U+009F) control characters.
+# Stripping every control char (including tab, LF, CR) keeps titles safe for
+# SMTP headers, Content-Disposition filenames, and headless-browser document.title.
+_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
+
+
+def sanitize_title(title: str) -> str:
+ """Remove all C0/C1 control characters from a title string."""
+ return _CONTROL_CHARS_RE.sub("", title)
+
def get_filename(model_name: str, model_id: int, skip_id: bool = False) -> str:
+ model_name = sanitize_title(model_name)
slug = secure_filename(model_name)
filename = slug if skip_id else f"{slug}_{model_id}"
return filename if slug else str(model_id)
diff --git a/tests/unit_tests/utils/test_file.py b/tests/unit_tests/utils/test_file.py
index a2168a7d927..4cbd99b7096 100644
--- a/tests/unit_tests/utils/test_file.py
+++ b/tests/unit_tests/utils/test_file.py
@@ -16,7 +16,7 @@
# under the License.
import pytest
-from superset.utils.file import get_filename
+from superset.utils.file import get_filename, sanitize_title
@pytest.mark.parametrize(
@@ -34,6 +34,9 @@ from superset.utils.file import get_filename
("你好", 475, True, "475"),
("Energy Sankey 你好", 475, False, "Energy_Sankey_475"),
("Energy Sankey 你好", 475, True, "Energy_Sankey"),
+ ("Energy\x08Sankey", 132, False, "EnergySankey_132"),
+ ("Energy\x08Sankey", 132, True, "EnergySankey"),
+ ("Sales\x7fReport", 1, False, "SalesReport_1"),
],
)
def test_get_filename(
@@ -41,3 +44,20 @@ def test_get_filename(
) -> None:
original_filename = get_filename(model_name, model_id, skip_id)
assert expected_filename == original_filename
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ ("normal", "normal"),
+ ("a\x08b", "ab"),
+ ("x\x09y", "xy"),
+ ("x\ny", "xy"),
+ ("x\ry", "xy"),
+ ("\x00\x01\x02", ""),
+ ("a\x7fb", "ab"),
+ ("a\x9fb", "ab"),
+ ],
+)
+def test_sanitize_title(raw: str, expected: str) -> None:
+ assert sanitize_title(raw) == expected