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