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

beto 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 03ad1789f02 feat(alerts/reports): external URL warning (#35021)
03ad1789f02 is described below

commit 03ad1789f022e1e581f73c4fe6e6844b30e5e82c
Author: Beto Dealmeida <[email protected]>
AuthorDate: Fri Mar 6 11:57:03 2026 -0500

    feat(alerts/reports): external URL warning (#35021)
---
 docs/admin_docs/configuration/alerts-reports.mdx   |  14 ++
 .../src/pages/RedirectWarning/index.tsx            | 175 +++++++++++++++++++++
 .../src/pages/RedirectWarning/utils.test.ts        | 124 +++++++++++++++
 .../src/pages/RedirectWarning/utils.ts             |  96 +++++++++++
 superset-frontend/src/views/routes.tsx             |  11 ++
 superset/config.py                                 |   2 +
 superset/initialization/__init__.py                |   2 +
 superset/reports/notifications/email.py            |   5 +
 superset/utils/link_redirect.py                    | 149 ++++++++++++++++++
 superset/views/redirect.py                         |  76 +++++++++
 tests/integration_tests/security_tests.py          |   1 +
 .../integration_tests/views/test_redirect_view.py  |  66 ++++++++
 tests/unit_tests/utils/test_link_redirect.py       | 143 +++++++++++++++++
 13 files changed, 864 insertions(+)

diff --git a/docs/admin_docs/configuration/alerts-reports.mdx 
b/docs/admin_docs/configuration/alerts-reports.mdx
index 8364b8e50ae..b66f641e290 100644
--- a/docs/admin_docs/configuration/alerts-reports.mdx
+++ b/docs/admin_docs/configuration/alerts-reports.mdx
@@ -233,6 +233,20 @@ def alert_dynamic_minimal_interval(**kwargs) -> int:
 ALERT_MINIMUM_INTERVAL = alert_dynamic_minimal_interval
 ```
 
+## External Link Redirection
+
+For security, Superset rewrites external links in alert/report email HTML so
+they go through a warning page before the user is navigated to the external
+site.  Internal links (matching your configured base URL) are not affected.
+
+```python
+# Disable external link redirection entirely (default: True)
+ALERT_REPORTS_ENABLE_LINK_REDIRECT = False
+```
+
+The feature uses `WEBDRIVER_BASEURL_USER_FRIENDLY` (or `WEBDRIVER_BASEURL`)
+to determine which hosts are internal.
+
 ## Troubleshooting
 
 There are many reasons that reports might not be working.  Try these steps to 
check for specific issues.
diff --git a/superset-frontend/src/pages/RedirectWarning/index.tsx 
b/superset-frontend/src/pages/RedirectWarning/index.tsx
new file mode 100644
index 00000000000..0f36da33ca8
--- /dev/null
+++ b/superset-frontend/src/pages/RedirectWarning/index.tsx
@@ -0,0 +1,175 @@
+/**
+ * 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 { useState, useMemo, useCallback, useEffect } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { css, styled, useTheme } from '@apache-superset/core/theme';
+import {
+  Button,
+  Card,
+  Checkbox,
+  Flex,
+  Typography,
+} from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { getTargetUrl, isUrlTrusted, trustUrl, isAllowedScheme } from 
'./utils';
+
+const PageContainer = styled(Flex)`
+  ${({ theme }) => css`
+    height: calc(100vh - 64px);
+    background-color: ${theme.colorBgLayout};
+    padding: ${theme.padding}px;
+  `}
+`;
+
+const WarningCard = styled(Card)`
+  ${({ theme }) => css`
+    max-width: 520px;
+    width: 100%;
+    box-shadow: ${theme.boxShadowSecondary};
+  `}
+`;
+
+const WarningHeader = styled(Flex)`
+  ${({ theme }) => css`
+    padding: ${theme.paddingLG}px ${theme.paddingXL}px;
+    border-bottom: 1px solid ${theme.colorBorderSecondary};
+  `}
+`;
+
+const WarningBody = styled.div`
+  ${({ theme }) => css`
+    padding: ${theme.paddingXL}px;
+  `}
+`;
+
+const UrlDisplay = styled(Flex)`
+  ${({ theme }) => css`
+    background-color: ${theme.colorFillQuaternary};
+    border-radius: ${theme.borderRadiusSM}px;
+    padding: ${theme.paddingSM}px ${theme.padding}px;
+    margin-bottom: ${theme.margin}px;
+  `}
+`;
+
+const UrlText = styled(Typography.Text)`
+  ${({ theme }) => css`
+    font-family: ${theme.fontFamilyCode};
+    font-size: ${theme.fontSize}px;
+    word-break: break-all;
+  `}
+`;
+
+const WarningFooter = styled(Flex)`
+  ${({ theme }) => css`
+    padding: ${theme.padding}px ${theme.paddingXL}px;
+    background-color: ${theme.colorFillAlter};
+    border-top: 1px solid ${theme.colorBorderSecondary};
+  `}
+`;
+
+const WarningTitle = styled(Typography.Title)`
+  && {
+    margin: 0;
+  }
+`;
+
+export default function RedirectWarning() {
+  const theme = useTheme();
+  const [trustChecked, setTrustChecked] = useState(false);
+
+  const targetUrl = useMemo(() => getTargetUrl(), []);
+
+  // Redirect immediately if the URL is already trusted
+  useEffect(() => {
+    if (targetUrl && isAllowedScheme(targetUrl) && isUrlTrusted(targetUrl)) {
+      window.location.href = targetUrl;
+    }
+  }, [targetUrl]);
+
+  const handleContinue = useCallback(() => {
+    if (!targetUrl || !isAllowedScheme(targetUrl)) return;
+    if (trustChecked) {
+      trustUrl(targetUrl);
+    }
+    window.location.href = targetUrl;
+  }, [trustChecked, targetUrl]);
+
+  const handleReturn = useCallback(() => {
+    window.location.href = '/';
+  }, []);
+
+  if (!targetUrl) {
+    return (
+      <PageContainer justify="center" align="center">
+        <WarningCard>
+          <WarningBody>
+            <Typography.Text type="danger">
+              {t('Missing URL parameter')}
+            </Typography.Text>
+          </WarningBody>
+        </WarningCard>
+      </PageContainer>
+    );
+  }
+
+  return (
+    <PageContainer justify="center" align="center">
+      <WarningCard>
+        <WarningHeader align="center" gap="middle">
+          <Icons.WarningOutlined iconColor={theme.colorWarning} iconSize="xl" 
/>
+          <WarningTitle level={4}>{t('External link warning')}</WarningTitle>
+        </WarningHeader>
+
+        <WarningBody>
+          <Typography.Paragraph type="secondary">
+            {t(
+              'This link will take you to an external website. We cannot 
guarantee the safety of external destinations.',
+            )}
+          </Typography.Paragraph>
+
+          <UrlDisplay align="center" gap="small">
+            <Icons.LinkOutlined iconColor={theme.colorTextTertiary} />
+            <UrlText>{targetUrl}</UrlText>
+          </UrlDisplay>
+
+          <Flex align="center" gap="small">
+            <Checkbox
+              checked={trustChecked}
+              onChange={e => setTrustChecked(e.target.checked)}
+            >
+              {t("Trust this URL and don't ask again")}
+            </Checkbox>
+          </Flex>
+
+          <Typography.Text type="secondary">
+            {t('Only proceed if you trust the destination or its source.')}
+          </Typography.Text>
+        </WarningBody>
+
+        <WarningFooter justify="flex-end" gap="small">
+          <Button onClick={handleReturn}>{t('Return to Superset')}</Button>
+          <Button type="primary" onClick={handleContinue}>
+            {t('Continue')}
+          </Button>
+        </WarningFooter>
+      </WarningCard>
+    </PageContainer>
+  );
+}
diff --git a/superset-frontend/src/pages/RedirectWarning/utils.test.ts 
b/superset-frontend/src/pages/RedirectWarning/utils.test.ts
new file mode 100644
index 00000000000..c9d2d7fbdc0
--- /dev/null
+++ b/superset-frontend/src/pages/RedirectWarning/utils.test.ts
@@ -0,0 +1,124 @@
+/**
+ * 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 { isAllowedScheme, getTargetUrl, isUrlTrusted, trustUrl } from 
'./utils';
+
+const TRUSTED_URLS_KEY = 'superset_trusted_urls';
+
+beforeEach(() => {
+  localStorage.clear();
+});
+
+test('isAllowedScheme accepts http URLs', () => {
+  expect(isAllowedScheme('http://example.com')).toBe(true);
+});
+
+test('isAllowedScheme accepts https URLs', () => {
+  expect(isAllowedScheme('https://example.com/page?q=1')).toBe(true);
+});
+
+test('isAllowedScheme blocks javascript: URLs', () => {
+  // oxlint-disable-next-line no-script-url -- testing that dangerous schemes 
are blocked
+  expect(isAllowedScheme('javascript:alert(1)')).toBe(false);
+});
+
+test('isAllowedScheme blocks data: URLs', () => {
+  expect(isAllowedScheme('data:text/html,<script>alert(1)</script>')).toBe(
+    false,
+  );
+});
+
+test('isAllowedScheme blocks vbscript: URLs', () => {
+  expect(isAllowedScheme('vbscript:MsgBox("XSS")')).toBe(false);
+});
+
+test('isAllowedScheme blocks file: URLs', () => {
+  expect(isAllowedScheme('file:///etc/passwd')).toBe(false);
+});
+
+test('isAllowedScheme allows relative URLs (unparseable as absolute)', () => {
+  expect(isAllowedScheme('/dashboard/1')).toBe(true);
+});
+
+test('getTargetUrl reads the url query parameter', () => {
+  Object.defineProperty(window, 'location', {
+    value: { search: '?url=https%3A%2F%2Fexample.com%2Fpage' },
+    writable: true,
+  });
+  expect(getTargetUrl()).toBe('https://example.com/page');
+});
+
+test('getTargetUrl returns empty string when url param is missing', () => {
+  Object.defineProperty(window, 'location', {
+    value: { search: '' },
+    writable: true,
+  });
+  expect(getTargetUrl()).toBe('');
+});
+
+test('getTargetUrl does not double-decode percent-encoded values', () => {
+  // %253A is the double-encoding of ":" — after one decode it should remain 
%3A
+  Object.defineProperty(window, 'location', {
+    value: { search: '?url=javascript%253Aalert(1)' },
+    writable: true,
+  });
+  expect(getTargetUrl()).toBe('javascript%3Aalert(1)');
+});
+
+test('trustUrl stores and isUrlTrusted retrieves a URL', () => {
+  const url = 'https://example.com/page';
+  expect(isUrlTrusted(url)).toBe(false);
+  trustUrl(url);
+  expect(isUrlTrusted(url)).toBe(true);
+});
+
+test('isUrlTrusted normalizes URLs for comparison', () => {
+  trustUrl('https://example.com/page/');
+  expect(isUrlTrusted('https://example.com/page')).toBe(true);
+});
+
+test('trustUrl does not add duplicates', () => {
+  trustUrl('https://example.com');
+  trustUrl('https://example.com');
+  const stored = JSON.parse(
+    localStorage.getItem(TRUSTED_URLS_KEY) ?? '[]',
+  ) as string[];
+  expect(stored).toHaveLength(1);
+});
+
+test('isUrlTrusted returns false when localStorage contains invalid data', () 
=> {
+  localStorage.setItem(TRUSTED_URLS_KEY, '"not-an-array"');
+  expect(isUrlTrusted('https://example.com')).toBe(false);
+});
+
+test('isUrlTrusted returns false when localStorage contains a non-array 
object', () => {
+  localStorage.setItem(TRUSTED_URLS_KEY, '{"foo":"bar"}');
+  expect(isUrlTrusted('https://example.com')).toBe(false);
+});
+
+test('trustUrl caps storage at 100 entries', () => {
+  const urls = Array.from({ length: 105 }, (_, i) => 
`https://example${i}.com`);
+  urls.forEach(url => trustUrl(url));
+  const stored = JSON.parse(
+    localStorage.getItem(TRUSTED_URLS_KEY) ?? '[]',
+  ) as string[];
+  expect(stored.length).toBeLessThanOrEqual(100);
+  // The most recent entries should be kept
+  expect(stored).toContain('https://example104.com');
+});
diff --git a/superset-frontend/src/pages/RedirectWarning/utils.ts 
b/superset-frontend/src/pages/RedirectWarning/utils.ts
new file mode 100644
index 00000000000..43aa834789a
--- /dev/null
+++ b/superset-frontend/src/pages/RedirectWarning/utils.ts
@@ -0,0 +1,96 @@
+/**
+ * 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.
+ */
+
+const TRUSTED_URLS_KEY = 'superset_trusted_urls';
+const MAX_TRUSTED_URLS = 100;
+const ALLOWED_SCHEMES = ['http:', 'https:'];
+
+/**
+ * Normalize a URL for comparison (origin + path without trailing slash + 
search).
+ */
+function normalizeUrl(url: string): string {
+  try {
+    const parsed = new URL(url);
+    return parsed.origin + parsed.pathname.replace(/\/$/, '') + parsed.search;
+  } catch {
+    return url;
+  }
+}
+
+/**
+ * Return true if the URL scheme is safe for navigation.
+ * Blocks javascript:, data:, vbscript:, file:, etc.
+ */
+export function isAllowedScheme(url: string): boolean {
+  try {
+    const parsed = new URL(url);
+    return ALLOWED_SCHEMES.includes(parsed.protocol);
+  } catch {
+    // relative URLs or unparseable — allow (they'll resolve against current 
origin)
+    return true;
+  }
+}
+
+/**
+ * Read the target URL from the current page's query string.
+ *
+ * URLSearchParams.get() already percent-decodes the value, so we must NOT
+ * call decodeURIComponent again (doing so would allow double-encoded
+ * payloads like `javascript%253Aalert(1)` to bypass scheme checks).
+ */
+export function getTargetUrl(): string {
+  const params = new URLSearchParams(window.location.search);
+  const url = params.get('url') ?? '';
+  return url.trim();
+}
+
+function getTrustedUrls(): string[] {
+  try {
+    const stored = localStorage.getItem(TRUSTED_URLS_KEY);
+    if (!stored) return [];
+    const parsed: unknown = JSON.parse(stored);
+    return Array.isArray(parsed) ? (parsed as string[]) : [];
+  } catch {
+    return [];
+  }
+}
+
+function saveTrustedUrls(urls: string[]): void {
+  const limited =
+    urls.length > MAX_TRUSTED_URLS ? urls.slice(-MAX_TRUSTED_URLS) : urls;
+  try {
+    localStorage.setItem(TRUSTED_URLS_KEY, JSON.stringify(limited));
+  } catch {
+    // Ignore storage errors (private browsing, quota exceeded, etc.)
+  }
+}
+
+export function isUrlTrusted(url: string): boolean {
+  const normalized = normalizeUrl(url);
+  return getTrustedUrls().some(t => normalizeUrl(t) === normalized);
+}
+
+export function trustUrl(url: string): void {
+  const normalized = normalizeUrl(url);
+  const trusted = getTrustedUrls();
+  if (!trusted.some(t => normalizeUrl(t) === normalized)) {
+    trusted.push(url);
+    saveTrustedUrls(trusted);
+  }
+}
diff --git a/superset-frontend/src/views/routes.tsx 
b/superset-frontend/src/views/routes.tsx
index 674fbc5846c..4f066e3ec2c 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -180,6 +180,13 @@ const FileHandler = lazy(
   () => import(/* webpackChunkName: "FileHandler" */ 'src/pages/FileHandler'),
 );
 
+const RedirectWarning = lazy(
+  () =>
+    import(
+      /* webpackChunkName: "RedirectWarning" */ 'src/pages/RedirectWarning'
+    ),
+);
+
 type Routes = {
   path: string;
   Component: ComponentType;
@@ -188,6 +195,10 @@ type Routes = {
 }[];
 
 export const routes: Routes = [
+  {
+    path: '/redirect/',
+    Component: RedirectWarning,
+  },
   {
     path: '/login/',
     Component: Login,
diff --git a/superset/config.py b/superset/config.py
index 30f0f801227..7477f7f4f9e 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1964,6 +1964,8 @@ ALERT_REPORTS_QUERY_EXECUTION_MAX_TRIES = 1
 # Custom width for screenshots
 ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH = 600
 ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH = 2400
+# Rewrite external links in alert/report emails to go through a warning page
+ALERT_REPORTS_ENABLE_LINK_REDIRECT = True
 # Set a minimum interval threshold between executions (for each Alert/Report)
 # Value should be an integer i.e. int(timedelta(minutes=5).total_seconds())
 # You can also assign a function to the config that returns the expected 
integer
diff --git a/superset/initialization/__init__.py 
b/superset/initialization/__init__.py
index abd2943f1f5..878ed2f52d5 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -207,6 +207,7 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
         from superset.views.groups import GroupsListView
         from superset.views.log.api import LogRestApi
         from superset.views.logs import ActionLogView
+        from superset.views.redirect import RedirectView
         from superset.views.roles import RolesListView
         from superset.views.sql_lab.views import (
             SavedQueryView,
@@ -446,6 +447,7 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
         appbuilder.add_view_no_menu(TaggedObjectsModelView)
         appbuilder.add_view_no_menu(TagView)
         appbuilder.add_view_no_menu(ReportView)
+        appbuilder.add_view_no_menu(RedirectView)
         appbuilder.add_view_no_menu(RoleRestAPI)
         appbuilder.add_view_no_menu(UserInfoView)
 
diff --git a/superset/reports/notifications/email.py 
b/superset/reports/notifications/email.py
index 4f3e24d9dcc..d0f51c9c4fb 100644
--- a/superset/reports/notifications/email.py
+++ b/superset/reports/notifications/email.py
@@ -34,6 +34,7 @@ from superset.reports.notifications.exceptions import 
NotificationError
 from superset.utils import json
 from superset.utils.core import HeaderDataType, send_email_smtp
 from superset.utils.decorators import statsd_gauge
+from superset.utils.link_redirect import process_html_links
 
 logger = logging.getLogger(__name__)
 
@@ -133,6 +134,9 @@ class EmailNotification(BaseNotification):  # pylint: 
disable=too-few-public-met
             attributes=ALLOWED_ATTRIBUTES,
         )
 
+        # Rewrite external links to go through the redirect warning page
+        description = process_html_links(description)
+
         # Strip malicious HTML from embedded data, allowing only table elements
         if self._content.embedded_data is not None:
             df = self._content.embedded_data
@@ -144,6 +148,7 @@ class EmailNotification(BaseNotification):  # pylint: 
disable=too-few-public-met
                 tags=TABLE_TAGS,
                 attributes=ALLOWED_TABLE_ATTRIBUTES,
             )
+            html_table = process_html_links(html_table)
         else:
             html_table = ""
 
diff --git a/superset/utils/link_redirect.py b/superset/utils/link_redirect.py
new file mode 100644
index 00000000000..b95f262f339
--- /dev/null
+++ b/superset/utils/link_redirect.py
@@ -0,0 +1,149 @@
+# 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.
+
+"""
+Utilities for processing links in alert/report emails.
+
+External links are rewritten to go through a redirect warning page so that
+recipients see a confirmation before navigating to an external site.
+"""
+
+import logging
+import re
+from urllib.parse import quote, urlparse
+
+from flask import current_app
+
+logger = logging.getLogger(__name__)
+
+# Matches href="..." in anchor tags (both single and double quotes)
+_HREF_RE = re.compile(
+    r"""(<a\s[^>]*?href\s*=\s*)(["'])(.*?)\2""",
+    re.IGNORECASE | re.DOTALL,
+)
+
+
+def _get_base_hosts() -> set[str]:
+    """Return the set of hosts that are considered internal (lower-cased)."""
+    hosts: set[str] = set()
+    for key in ("WEBDRIVER_BASEURL_USER_FRIENDLY", "WEBDRIVER_BASEURL"):
+        url = current_app.config.get(key, "")
+        if url:
+            parsed = urlparse(url)
+            if parsed.scheme and parsed.netloc:
+                hosts.add(parsed.netloc.lower())
+    return hosts
+
+
+def _get_redirect_base() -> str:
+    """Return the base URL used to build redirect links."""
+    for key in ("WEBDRIVER_BASEURL_USER_FRIENDLY", "WEBDRIVER_BASEURL"):
+        url = current_app.config.get(key, "")
+        if url:
+            return url.rstrip("/")
+    return ""
+
+
+def _is_external(href: str, base_hosts: set[str]) -> bool:
+    """Return True if *href* points to an external host."""
+    parsed = urlparse(href)
+    # Only rewrite http(s) links with a host that differs from ours
+    if parsed.scheme not in ("http", "https"):
+        return False
+    return bool(parsed.netloc) and parsed.netloc.lower() not in base_hosts
+
+
+def _replace_href(
+    match: re.Match[str],
+    base_hosts: set[str],
+    redirect_base: str,
+) -> str:
+    """Regex replacer: rewrite external hrefs to go through the redirect 
page."""
+    prefix, quote_char, href = match.group(1), match.group(2), match.group(3)
+    href = href.strip()
+
+    # Don't double-redirect
+    if "/redirect/" in href:
+        return match.group(0)
+
+    if not _is_external(href, base_hosts):
+        return match.group(0)
+
+    redirect_url = f"{redirect_base}/redirect/?url={quote(href, safe='')}"
+    return f"{prefix}{quote_char}{redirect_url}{quote_char}"
+
+
+def process_html_links(html_content: str) -> str:
+    """
+    Rewrite external links in *html_content* to go through the redirect page.
+
+    Internal links (matching the configured base URL hosts) are left untouched.
+    """
+    if not html_content or not html_content.strip():
+        return html_content
+
+    if not current_app.config.get("ALERT_REPORTS_ENABLE_LINK_REDIRECT", True):
+        return html_content
+
+    base_hosts = _get_base_hosts()
+    if not base_hosts:
+        logger.warning("No base URL configured, skipping link redirect 
processing")
+        return html_content
+
+    redirect_base = _get_redirect_base()
+    if not redirect_base:
+        return html_content
+
+    try:
+        return _HREF_RE.sub(
+            lambda m: _replace_href(m, base_hosts, redirect_base),
+            html_content,
+        )
+    except Exception:
+        logger.warning("Failed to process HTML links", exc_info=True)
+        return html_content
+
+
+def is_safe_redirect_url(url: str) -> bool:
+    """
+    Return True if *url* is an internal Superset URL (safe to redirect to
+    without showing a warning).
+    """
+    if not url or not url.strip():
+        return False
+
+    stripped = url.strip()
+
+    # Block protocol-relative URLs
+    if stripped.startswith("//") or stripped.startswith("\\\\"):
+        return False
+
+    parsed = urlparse(stripped)
+
+    # Relative paths are safe
+    if not parsed.scheme and not parsed.netloc:
+        return True
+
+    # Only allow http(s)
+    if parsed.scheme not in ("http", "https"):
+        return False
+
+    base_hosts = _get_base_hosts()
+    if not base_hosts:
+        return False
+
+    return parsed.netloc.lower() in base_hosts
diff --git a/superset/views/redirect.py b/superset/views/redirect.py
new file mode 100644
index 00000000000..90d0452e62c
--- /dev/null
+++ b/superset/views/redirect.py
@@ -0,0 +1,76 @@
+# 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.
+
+"""
+View that handles external-link redirects with a warning page.
+
+Links in alert/report emails are rewritten to point here.  Internal links
+redirect immediately; external links are shown to the user for confirmation
+via the React ``RedirectWarning`` page.
+"""
+
+import logging
+from urllib.parse import urlparse
+
+from flask import abort, redirect, request
+from flask_appbuilder import expose
+
+from superset import is_feature_enabled
+from superset.superset_typing import FlaskResponse
+from superset.utils.link_redirect import is_safe_redirect_url
+from superset.views.base import BaseSupersetView
+
+logger = logging.getLogger(__name__)
+
+DANGEROUS_SCHEMES: frozenset[str] = frozenset(
+    ("javascript", "data", "vbscript", "file")
+)
+
+
+class RedirectView(BaseSupersetView):
+    """
+    Warning page for external links found in alert/report emails.
+
+    This endpoint is publicly accessible (no authentication required)
+    because email recipients may not have an active Superset session.
+    """
+
+    route_base = "/redirect"
+
+    @expose("/")
+    def redirect_warning(self) -> FlaskResponse:
+        """Validate the target URL and either redirect or show the warning 
page."""
+        if not is_feature_enabled("ALERT_REPORTS"):
+            abort(404)
+
+        target_url = request.args.get("url", "").strip()
+
+        if not target_url:
+            abort(400, description="Missing URL parameter")
+
+        # Block dangerous schemes using urlparse for robust detection
+        parsed = urlparse(target_url)
+        if parsed.scheme.lower() in DANGEROUS_SCHEMES:
+            logger.warning("Blocked dangerous URL scheme: %s", target_url[:80])
+            abort(400, description="Invalid URL scheme")
+
+        # Internal URLs redirect immediately
+        if is_safe_redirect_url(target_url):
+            return redirect(target_url)
+
+        # External URLs: render the React warning page
+        return super().render_app_template()
diff --git a/tests/integration_tests/security_tests.py 
b/tests/integration_tests/security_tests.py
index c7bc6a8e862..2307d522a16 100644
--- a/tests/integration_tests/security_tests.py
+++ b/tests/integration_tests/security_tests.py
@@ -1662,6 +1662,7 @@ class TestRolePermission(SupersetTestCase):
             ["SupersetAuthView", "logout"],
             ["SupersetRegisterUserView", "register"],
             ["SupersetRegisterUserView", "activation"],
+            ["RedirectView", "redirect_warning"],
         ]
         unsecured_views = []
         for view_class in appbuilder.baseviews:
diff --git a/tests/integration_tests/views/test_redirect_view.py 
b/tests/integration_tests/views/test_redirect_view.py
new file mode 100644
index 00000000000..b72d3f9b1ef
--- /dev/null
+++ b/tests/integration_tests/views/test_redirect_view.py
@@ -0,0 +1,66 @@
+# 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 tests.conftest import with_config
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.conftest import with_feature_flags
+
+REDIRECT_CONFIG = {
+    "WEBDRIVER_BASEURL": "http://localhost:8088";,
+    "WEBDRIVER_BASEURL_USER_FRIENDLY": "http://localhost:8088";,
+}
+
+
+class TestRedirectView(SupersetTestCase):
+    """Integration tests for the /redirect/ endpoint."""
+
+    @with_feature_flags(ALERT_REPORTS=True)
+    @with_config(REDIRECT_CONFIG)
+    def test_missing_url_returns_400(self):
+        resp = self.client.get("/redirect/")
+        assert resp.status_code == 400
+
+    @with_feature_flags(ALERT_REPORTS=True)
+    @with_config(REDIRECT_CONFIG)
+    def test_dangerous_scheme_returns_400(self):
+        resp = self.client.get("/redirect/?url=javascript:alert(1)")
+        assert resp.status_code == 400
+
+    @with_feature_flags(ALERT_REPORTS=True)
+    @with_config(REDIRECT_CONFIG)
+    def test_internal_url_redirects(self):
+        resp = self.client.get(
+            "/redirect/?url=http://localhost:8088/dashboard/1";,
+            follow_redirects=False,
+        )
+        assert resp.status_code == 302
+        assert resp.headers["Location"] == "http://localhost:8088/dashboard/1";
+
+    @with_feature_flags(ALERT_REPORTS=True)
+    @with_config(REDIRECT_CONFIG)
+    def test_external_url_renders_page(self):
+        resp = self.client.get(
+            "/redirect/?url=https://external.com/page";,
+        )
+        assert resp.status_code == 200
+
+    @with_feature_flags(ALERT_REPORTS=False)
+    def test_feature_flag_disabled_returns_404(self):
+        resp = self.client.get(
+            "/redirect/?url=https://external.com";,
+        )
+        assert resp.status_code == 404
diff --git a/tests/unit_tests/utils/test_link_redirect.py 
b/tests/unit_tests/utils/test_link_redirect.py
new file mode 100644
index 00000000000..c02eaa04a9b
--- /dev/null
+++ b/tests/unit_tests/utils/test_link_redirect.py
@@ -0,0 +1,143 @@
+# 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 pytest
+from flask import Flask
+
+from superset.utils.link_redirect import is_safe_redirect_url, 
process_html_links
+
+
[email protected]
+def app():
+    """Minimal Flask app with the config keys used by link_redirect."""
+    application = Flask(__name__)
+    application.config["WEBDRIVER_BASEURL"] = "http://superset.example.com";
+    application.config["WEBDRIVER_BASEURL_USER_FRIENDLY"] = (
+        "https://superset.example.com";
+    )
+    application.config["ALERT_REPORTS_ENABLE_LINK_REDIRECT"] = True
+    with application.app_context():
+        yield application
+
+
+# --------------------------------------------------------------------------- #
+# process_html_links
+# --------------------------------------------------------------------------- #
+
+
+def test_external_link_is_rewritten(app: Flask) -> None:
+    html = '<a href="https://evil.com/page";>Click</a>'
+    result = process_html_links(html)
+    assert "superset.example.com/redirect/?url=https%3A%2F%2Fevil.com%2Fpage" 
in result
+    assert "evil.com/page" not in result.split("url=")[0]
+
+
+def test_internal_link_is_not_rewritten(app: Flask) -> None:
+    html = '<a href="https://superset.example.com/dashboard/1";>Dashboard</a>'
+    result = process_html_links(html)
+    assert result == html
+
+
+def test_relative_link_is_not_rewritten(app: Flask) -> None:
+    html = '<a href="/dashboard/1">Dashboard</a>'
+    result = process_html_links(html)
+    assert result == html
+
+
+def test_no_double_redirect(app: Flask) -> None:
+    html = (
+        '<a href="https://superset.example.com/redirect/'
+        '?url=https%3A%2F%2Fexternal.com">Already redirected</a>'
+    )
+    result = process_html_links(html)
+    assert result.count("/redirect/") == 1
+
+
+def test_multiple_links(app: Flask) -> None:
+    html = (
+        '<a href="https://evil.com";>Bad</a>'
+        '<a href="https://superset.example.com/x";>Good</a>'
+        '<a href="https://other.com";>Other</a>'
+    )
+    result = process_html_links(html)
+    assert result.count("/redirect/?url=") == 2
+    assert "superset.example.com/x" in result
+
+
+def test_disabled_via_config(app: Flask) -> None:
+    app.config["ALERT_REPORTS_ENABLE_LINK_REDIRECT"] = False
+    html = '<a href="https://evil.com";>Click</a>'
+    assert process_html_links(html) == html
+
+
+def test_empty_html(app: Flask) -> None:
+    assert process_html_links("") == ""
+    assert process_html_links("   ") == "   "
+
+
+def test_no_base_url_configured(app: Flask) -> None:
+    app.config["WEBDRIVER_BASEURL"] = ""
+    app.config["WEBDRIVER_BASEURL_USER_FRIENDLY"] = ""
+    html = '<a href="https://evil.com";>Click</a>'
+    assert process_html_links(html) == html
+
+
+def test_single_quoted_href(app: Flask) -> None:
+    html = "<a href='https://evil.com'>Click</a>"
+    result = process_html_links(html)
+    assert "/redirect/?url=" in result
+
+
+def test_html_without_links(app: Flask) -> None:
+    html = "<p>No links here</p>"
+    assert process_html_links(html) == html
+
+
+# --------------------------------------------------------------------------- #
+# is_safe_redirect_url
+# --------------------------------------------------------------------------- #
+
+
+def test_safe_internal_url(app: Flask) -> None:
+    assert is_safe_redirect_url("https://superset.example.com/dashboard/1";)
+
+
+def test_safe_relative_url(app: Flask) -> None:
+    assert is_safe_redirect_url("/dashboard/1")
+
+
+def test_unsafe_external_url(app: Flask) -> None:
+    assert not is_safe_redirect_url("https://evil.com/phish";)
+
+
+def test_unsafe_javascript_scheme(app: Flask) -> None:
+    assert not is_safe_redirect_url("javascript:alert(1)")
+
+
+def test_unsafe_protocol_relative(app: Flask) -> None:
+    assert not is_safe_redirect_url("//evil.com/x")
+
+
+def test_unsafe_empty(app: Flask) -> None:
+    assert not is_safe_redirect_url("")
+    assert not is_safe_redirect_url("   ")
+
+
+def test_unsafe_no_config(app: Flask) -> None:
+    app.config["WEBDRIVER_BASEURL"] = ""
+    app.config["WEBDRIVER_BASEURL_USER_FRIENDLY"] = ""
+    assert not is_safe_redirect_url("https://anything.com";)


Reply via email to