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

beto pushed a commit to branch url-redirect
in repository https://gitbox.apache.org/repos/asf/superset.git

commit c5537403b9c53111e4640befd939dc1b12d305e2
Author: Beto Dealmeida <[email protected]>
AuthorDate: Thu Sep 4 11:28:24 2025 -0400

    feat(alerts/reports): external URL warning
---
 requirements/base.in                               |   3 +
 requirements/base.txt                              |   5 +
 superset/initialization/__init__.py                |   4 +
 superset/reports/notifications/email.py            |   4 +
 superset/templates/superset/redirect_warning.html  | 159 ++++++++++++++
 superset/utils/link_redirect.py                    | 135 ++++++++++++
 superset/views/redirect.py                         |  69 ++++++
 .../integration_tests/views/test_redirect_view.py  | 224 ++++++++++++++++++++
 tests/unit_tests/utils/test_link_redirect.py       | 232 +++++++++++++++++++++
 9 files changed, 835 insertions(+)

diff --git a/requirements/base.in b/requirements/base.in
index d110fa8931..fb65ff91be 100644
--- a/requirements/base.in
+++ b/requirements/base.in
@@ -36,3 +36,6 @@ marshmallow-sqlalchemy>=1.3.0,<1.4.1
 
 # needed for python 3.12 support
 openapi-schema-validator>=0.6.3
+
+# For parsing and processing HTML content in alerts/reports
+beautifulsoup4>=4.12.0
diff --git a/requirements/base.txt b/requirements/base.txt
index 4357d92701..0a49655039 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -28,6 +28,8 @@ backoff==2.2.1
     # via apache-superset (pyproject.toml)
 bcrypt==4.3.0
     # via paramiko
+beautifulsoup4==4.13.5
+    # via -r requirements/base.in
 billiard==4.2.1
     # via celery
 blinker==1.9.0
@@ -375,6 +377,8 @@ sniffio==1.3.1
     # via trio
 sortedcontainers==2.4.0
     # via trio
+soupsieve==2.8
+    # via beautifulsoup4
 sqlalchemy==1.4.54
     # via
     #   apache-superset (pyproject.toml)
@@ -404,6 +408,7 @@ typing-extensions==4.14.0
     # via
     #   apache-superset (pyproject.toml)
     #   alembic
+    #   beautifulsoup4
     #   cattrs
     #   limits
     #   pyopenssl
diff --git a/superset/initialization/__init__.py 
b/superset/initialization/__init__.py
index cdecd96b92..884174040a 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -434,6 +434,10 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
         appbuilder.add_view_no_menu(RoleRestAPI)
         appbuilder.add_view_no_menu(UserInfoView)
 
+        from superset.views.redirect import RedirectView
+
+        appbuilder.add_view_no_menu(RedirectView)
+
         #
         # Add links
         #
diff --git a/superset/reports/notifications/email.py 
b/superset/reports/notifications/email.py
index 22a0d38bdd..7827a6fb86 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,
         )
 
+        # Process links to add redirect for external URLs
+        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
diff --git a/superset/templates/superset/redirect_warning.html 
b/superset/templates/superset/redirect_warning.html
new file mode 100644
index 0000000000..64d15cb56d
--- /dev/null
+++ b/superset/templates/superset/redirect_warning.html
@@ -0,0 +1,159 @@
+{#
+  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.
+#}
+
+<!DOCTYPE html>
+<html>
+  {% import 'appbuilder/general/lib.html' as lib %}
+  {% from 'superset/partials/asset_bundle.html' import css_bundle, js_bundle 
with context %}
+  {% import "superset/macros.html" as macros %}
+  {% set favicons = appbuilder.app.config['FAVICONS'] %}
+
+  <head>
+    <title>External Link - Apache Superset</title>
+
+    {% block head_css %}
+      {% for favicon in favicons %}
+        <link
+          rel="{{ favicon.rel if favicon.rel else 'icon' }}"
+          type="{{ favicon.type if favicon.type else 'image/png' }}"
+          {% if favicon.sizes %}sizes="{{ favicon.sizes }}"{% endif %}
+          href="{{ '' if favicon.href.startswith('http') else assets_prefix 
}}{{ favicon.href }}"
+        />
+      {% endfor %}
+
+      <style nonce="{{ macros.get_nonce() }}">
+        body {
+          font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 
"Helvetica Neue", Arial, sans-serif;
+          margin: 0;
+          padding: 0;
+          background-color: #f7f7f7;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          min-height: 100vh;
+        }
+        .redirect-container {
+          background: white;
+          border-radius: 8px;
+          box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+          max-width: 600px;
+          padding: 40px;
+          margin: 20px;
+        }
+        .warning-icon {
+          display: inline-block;
+          width: 48px;
+          height: 48px;
+          margin-bottom: 20px;
+        }
+        .warning-icon svg {
+          width: 100%;
+          height: 100%;
+          fill: #ff9800;
+        }
+        h1 {
+          color: #333;
+          font-size: 24px;
+          margin: 0 0 20px 0;
+          font-weight: 600;
+        }
+        .warning-message {
+          color: #666;
+          line-height: 1.6;
+          margin-bottom: 20px;
+        }
+        .url-display {
+          background: #f5f5f5;
+          border: 1px solid #e0e0e0;
+          border-radius: 4px;
+          padding: 12px;
+          margin: 20px 0;
+          word-break: break-all;
+          font-family: monospace;
+          font-size: 14px;
+          color: #555;
+        }
+        .button-group {
+          display: flex;
+          gap: 12px;
+          justify-content: flex-end;
+          margin-top: 30px;
+        }
+        .btn {
+          padding: 10px 20px;
+          border-radius: 4px;
+          text-decoration: none;
+          font-size: 14px;
+          font-weight: 500;
+          cursor: pointer;
+          transition: all 0.2s;
+          border: none;
+        }
+        .btn-primary {
+          background: #1890ff;
+          color: white;
+        }
+        .btn-primary:hover {
+          background: #40a9ff;
+        }
+        .btn-secondary {
+          background: #f5f5f5;
+          color: #666;
+          border: 1px solid #d9d9d9;
+        }
+        .btn-secondary:hover {
+          background: #e6e6e6;
+          color: #333;
+        }
+        .disclaimer {
+          font-size: 12px;
+          color: #999;
+          margin-top: 20px;
+          padding-top: 20px;
+          border-top: 1px solid #f0f0f0;
+        }
+      </style>
+    {% endblock %}
+
+    {{ js_bundle(assets_prefix, 'theme') }}
+  </head>
+
+  <body>
+    <div class="redirect-container">
+      <div class="warning-icon">
+        <svg xmlns="http://www.w3.org/2000/svg"; viewBox="0 0 24 24">
+          <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 
2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 
8zm-1-13h2v6h-2zm0 8h2v2h-2z"/>
+        </svg>
+      </div>
+
+      <h1>You are leaving Apache Superset</h1>
+
+      <div class="warning-message">
+        This link is sending you outside of Superset to an external website.
+        Please be careful when entering personal information or credentials on 
external sites.
+      </div>
+
+      <div class="url-display">{{ target_url }}</div>
+
+      <div class="button-group">
+        <button class="btn btn-secondary" onclick="window.history.back();">Go 
Back</button>
+        <a href="{{ target_url }}" class="btn btn-primary" rel="noopener 
noreferrer">Continue to External Site</a>
+      </div>
+
+      <div class="disclaimer">
+        Apache Superset is not responsible for the content or privacy 
practices of external websites.
+      </div>
+    </div>
+  </body>
+</html>
diff --git a/superset/utils/link_redirect.py b/superset/utils/link_redirect.py
new file mode 100644
index 0000000000..a80d6be13e
--- /dev/null
+++ b/superset/utils/link_redirect.py
@@ -0,0 +1,135 @@
+# 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 logging
+from typing import Optional
+from urllib.parse import quote, urlparse
+
+from bs4 import BeautifulSoup
+from flask import current_app
+
+logger = logging.getLogger(__name__)
+
+
+def process_html_links(html_content: str, base_url: Optional[str] = None) -> 
str:
+    """
+    Process HTML content to replace external links with redirect URLs.
+
+    This function parses HTML content and replaces any external links
+    (those not pointing to the Superset instance) with redirect URLs that
+    will show a warning before navigating to the external site.
+
+    Args:
+        html_content: The HTML content to process
+        base_url: The base URL of the Superset instance. If not provided,
+                  will attempt to get from app config
+
+    Returns:
+        The processed HTML content with external links replaced
+    """
+    if not html_content:
+        return html_content
+
+    try:
+        # Parse the HTML content
+        soup = BeautifulSoup(html_content, "html.parser")
+
+        # Get the base URL from config if not provided
+        if base_url is None:
+            base_url = current_app.config.get(
+                "WEBDRIVER_BASEURL_USER_FRIENDLY",
+                current_app.config.get("WEBDRIVER_BASEURL", ""),
+            )
+
+        # Parse base URL to get the host
+        base_parsed = urlparse(base_url)
+        base_host = base_parsed.netloc if base_parsed.netloc else 
base_parsed.path
+
+        # Find all anchor tags with href attribute
+        for link in soup.find_all("a", href=True):
+            original_url = link["href"]
+
+            # Skip if it's already a redirect URL
+            if "/redirect?" in original_url:
+                continue
+
+            # Parse the URL
+            parsed_url = urlparse(original_url)
+
+            # Check if it's an external link
+            # Consider a link external if it has a scheme (http/https) and
+            # the host is different from our base host
+            if parsed_url.scheme in ("http", "https"):
+                link_host = parsed_url.netloc
+
+                # If the hosts don't match, it's an external link
+                if link_host and link_host != base_host:
+                    # Create the redirect URL
+                    redirect_url = f"/redirect?url={quote(original_url, 
safe='')}"
+                    link["href"] = redirect_url
+
+                    # Optionally add a visual indicator
+                    if current_app.config.get(
+                        "ALERT_REPORTS_EXTERNAL_LINK_INDICATOR", True
+                    ):
+                        # Add external link icon or class
+                        existing_class = link.get("class", [])
+                        if isinstance(existing_class, str):
+                            existing_class = existing_class.split()
+                        existing_class.append("external-link")
+                        link["class"] = existing_class
+
+        # Return the modified HTML
+        return str(soup)
+
+    except Exception as ex:
+        logger.warning(f"Failed to process HTML links: {str(ex)}")
+        # On error, return the original HTML unchanged
+        return html_content
+
+
+def is_safe_redirect_url(url: str, base_url: Optional[str] = None) -> bool:
+    """
+    Check if a URL is safe to redirect to (i.e., it's a Superset URL).
+
+    Args:
+        url: The URL to check
+        base_url: The base URL of the Superset instance
+
+    Returns:
+        True if the URL is safe (internal), False otherwise
+    """
+    if not url:
+        return False
+
+    # Get the base URL from config if not provided
+    if base_url is None:
+        base_url = current_app.config.get(
+            "WEBDRIVER_BASEURL_USER_FRIENDLY",
+            current_app.config.get("WEBDRIVER_BASEURL", ""),
+        )
+
+    # Parse both URLs
+    parsed_url = urlparse(url)
+    base_parsed = urlparse(base_url)
+
+    # Get the hosts
+    url_host = parsed_url.netloc
+    base_host = base_parsed.netloc if base_parsed.netloc else base_parsed.path
+
+    # Check if it's the same host
+    return url_host == base_host
diff --git a/superset/views/redirect.py b/superset/views/redirect.py
new file mode 100644
index 0000000000..9209d91adf
--- /dev/null
+++ b/superset/views/redirect.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.
+
+import logging
+from urllib.parse import unquote
+
+from flask import abort, redirect, request
+from flask_appbuilder import expose
+from flask_appbuilder.security.decorators import has_access
+
+from superset.superset_typing import FlaskResponse
+from superset.utils.link_redirect import is_safe_redirect_url
+from superset.views.base import SupersetModelView
+
+logger = logging.getLogger(__name__)
+
+
+class RedirectView(SupersetModelView):
+    """
+    View for handling external link redirects with warning page
+    """
+
+    route_base = "/redirect"
+    class_permission_name = "RedirectView"
+
+    @expose("/")
+    @has_access
+    def redirect_warning(self) -> FlaskResponse:
+        """
+        Show a warning page before redirecting to an external URL
+        """
+        # Get the target URL from query parameters
+        target_url = request.args.get("url", "")
+
+        if not target_url:
+            logger.warning("Redirect requested without URL parameter")
+            abort(400, description="Missing URL parameter")
+
+        # Decode the URL
+        try:
+            target_url = unquote(target_url)
+        except Exception as ex:
+            logger.error(f"Failed to decode URL: {str(ex)}")
+            abort(400, description="Invalid URL parameter")
+
+        # Check if this is actually an external URL
+        if is_safe_redirect_url(target_url):
+            # If it's a safe/internal URL, redirect directly
+            return redirect(target_url)
+
+        # Otherwise, show the warning page
+        return self.render_template(
+            "superset/redirect_warning.html",
+            target_url=target_url,
+        )
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 0000000000..ac182d55c9
--- /dev/null
+++ b/tests/integration_tests/views/test_redirect_view.py
@@ -0,0 +1,224 @@
+# 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 urllib.parse import quote
+
+from tests.conftest import with_config
+from tests.integration_tests.base_tests import SupersetTestCase
+
+
+class TestRedirectView(SupersetTestCase):
+    """Test the redirect view functionality"""
+
+    def test_redirect_warning_external_url(self):
+        """Test that external URLs show the warning page"""
+        self.login(username="admin")
+
+        external_url = "https://evil.com/phishing";
+        encoded_url = quote(external_url, safe="")
+
+        response = self.client.get(f"/redirect/?url={encoded_url}")
+
+        # Should return 200 and show warning page
+        assert response.status_code == 200
+
+        # Check that the warning page content is present
+        assert b"You are leaving Apache Superset" in response.data
+        assert external_url.encode() in response.data
+        assert b"Continue to External Site" in response.data
+        assert b"Go Back" in response.data
+
+    def test_redirect_warning_multiple_external_urls(self):
+        """Test various external URLs"""
+        self.login(username="admin")
+
+        external_urls = [
+            "https://google.com";,
+            "http://example.org/page";,
+            "https://github.com/apache/superset";,
+        ]
+
+        for url in external_urls:
+            encoded_url = quote(url, safe="")
+            response = self.client.get(f"/redirect/?url={encoded_url}")
+
+            # Should show warning page for all external URLs
+            assert response.status_code == 200
+            assert b"You are leaving Apache Superset" in response.data
+            assert url.encode() in response.data
+
+    @with_config({"WEBDRIVER_BASEURL": "http://localhost:8088/"})
+    def test_redirect_internal_url_direct_redirect(self):
+        """Test that internal URLs redirect directly without warning"""
+        self.login(username="admin")
+
+        internal_url = "http://localhost:8088/dashboard/1";
+        encoded_url = quote(internal_url, safe="")
+
+        response = self.client.get(
+            f"/redirect/?url={encoded_url}", follow_redirects=False
+        )
+
+        # Should redirect directly to the internal URL
+        assert response.status_code in (301, 302, 303, 307, 308)
+        assert response.location == internal_url
+
+    def test_redirect_no_url_parameter(self):
+        """Test redirect endpoint without URL parameter"""
+        self.login(username="admin")
+
+        response = self.client.get("/redirect/")
+
+        # Should return 400 Bad Request
+        assert response.status_code == 400
+
+    def test_redirect_empty_url_parameter(self):
+        """Test redirect endpoint with empty URL parameter"""
+        self.login(username="admin")
+
+        response = self.client.get("/redirect/?url=")
+
+        # Should return 400 Bad Request
+        assert response.status_code == 400
+
+    def test_redirect_malformed_url(self):
+        """Test redirect with malformed URL encoding"""
+        self.login(username="admin")
+
+        # Send a malformed percent-encoded URL
+        response = self.client.get("/redirect/?url=%ZZ%XX%YY")
+
+        # Should handle gracefully
+        assert response.status_code in (200, 400)
+
+    def test_redirect_requires_authentication(self):
+        """Test that redirect endpoint requires authentication"""
+        # Without login
+        external_url = "https://evil.com";
+        encoded_url = quote(external_url, safe="")
+
+        response = self.client.get(
+            f"/redirect/?url={encoded_url}", follow_redirects=False
+        )
+
+        # Should redirect to login
+        assert response.status_code in (301, 302, 303, 307, 308)
+        assert "/login/" in response.location or "login" in 
response.location.lower()
+
+    def test_redirect_warning_page_content(self):
+        """Test the content of the warning page"""
+        self.login(username="admin")
+
+        external_url = "https://suspicious-site.com/malware";
+        encoded_url = quote(external_url, safe="")
+
+        response = self.client.get(f"/redirect/?url={encoded_url}")
+
+        # Check all expected elements are present
+        assert response.status_code == 200
+
+        # Warning message
+        assert b"You are leaving Apache Superset" in response.data
+        assert b"This link is sending you outside of Superset" in response.data
+
+        # URL display
+        assert external_url.encode() in response.data
+
+        # Buttons
+        assert b"Go Back" in response.data
+        assert b"Continue to External Site" in response.data
+
+        # Disclaimer
+        assert b"not responsible for the content" in response.data
+
+    def test_redirect_javascript_url(self):
+        """Test that JavaScript URLs are handled safely"""
+        self.login(username="admin")
+
+        js_url = "javascript:alert('XSS')"
+        encoded_url = quote(js_url, safe="")
+
+        response = self.client.get(f"/redirect/?url={encoded_url}")
+
+        # Should show warning page (not execute JavaScript)
+        assert response.status_code == 200
+        assert b"You are leaving Apache Superset" in response.data
+
+    def test_redirect_data_url(self):
+        """Test that data URLs are handled"""
+        self.login(username="admin")
+
+        data_url = "data:text/html,<script>alert('XSS')</script>"
+        encoded_url = quote(data_url, safe="")
+
+        response = self.client.get(f"/redirect/?url={encoded_url}")
+
+        # Should show warning page
+        assert response.status_code == 200
+        assert b"You are leaving Apache Superset" in response.data
+
+    @with_config({"WEBDRIVER_BASEURL": "https://superset.company.com/"})
+    def test_redirect_with_configured_base_url(self):
+        """Test redirect with a configured base URL"""
+        self.login(username="admin")
+
+        # Test internal URL with configured base
+        internal_url = "https://superset.company.com/dashboard/list";
+        encoded_url = quote(internal_url, safe="")
+
+        response = self.client.get(
+            f"/redirect/?url={encoded_url}", follow_redirects=False
+        )
+
+        # Should redirect directly (internal)
+        assert response.status_code in (301, 302, 303, 307, 308)
+
+        # Test external URL
+        external_url = "https://other.company.com/page";
+        encoded_url = quote(external_url, safe="")
+
+        response = self.client.get(f"/redirect/?url={encoded_url}")
+
+        # Should show warning page (external)
+        assert response.status_code == 200
+        assert b"You are leaving Apache Superset" in response.data
+
+    def test_redirect_preserves_url_parameters(self):
+        """Test that URL parameters are preserved in the redirect"""
+        self.login(username="admin")
+
+        url_with_params = "https://external.com/search?q=superset&page=1";
+        encoded_url = quote(url_with_params, safe="")
+
+        response = self.client.get(f"/redirect/?url={encoded_url}")
+
+        # The full URL with parameters should be displayed
+        assert response.status_code == 200
+        assert url_with_params.encode() in response.data
+
+    def test_redirect_handles_url_fragments(self):
+        """Test that URL fragments (anchors) are preserved"""
+        self.login(username="admin")
+
+        url_with_fragment = "https://docs.example.com/guide#section-3";
+        encoded_url = quote(url_with_fragment, safe="")
+
+        response = self.client.get(f"/redirect/?url={encoded_url}")
+
+        # The full URL with fragment should be displayed
+        assert response.status_code == 200
+        assert url_with_fragment.encode() in response.data
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 0000000000..dcfec75203
--- /dev/null
+++ b/tests/unit_tests/utils/test_link_redirect.py
@@ -0,0 +1,232 @@
+# 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():
+    """Create a Flask app for testing"""
+    app = Flask(__name__)
+    app.config["WEBDRIVER_BASEURL"] = "http://superset.example.com/";
+    app.config["WEBDRIVER_BASEURL_USER_FRIENDLY"] = 
"http://superset.example.com/";
+    app.config["ALERT_REPORTS_EXTERNAL_LINK_INDICATOR"] = True
+    return app
+
+
+def test_process_html_links_external_urls(app):
+    """Test that external URLs are replaced with redirect URLs"""
+    with app.app_context():
+        html = """
+        <div>
+            <a href="https://evil.com/phishing";>Click here</a>
+            <a href="http://external.site/page";>External link</a>
+        </div>
+        """
+
+        result = process_html_links(html)
+
+        # Check that external links are redirected
+        assert "/redirect?url=https%3A%2F%2Fevil.com%2Fphishing" in result
+        assert "/redirect?url=http%3A%2F%2Fexternal.site%2Fpage" in result
+
+        # Check that external-link class is added
+        assert 'class="external-link"' in result or "class='external-link'" in 
result
+
+
+def test_process_html_links_internal_urls(app):
+    """Test that internal URLs are not modified"""
+    with app.app_context():
+        html = """
+        <div>
+            <a href="http://superset.example.com/dashboard/1";>Dashboard</a>
+            <a href="http://superset.example.com/chart/list";>Charts</a>
+        </div>
+        """
+
+        result = process_html_links(html)
+
+        # Check that internal links are NOT redirected
+        assert "http://superset.example.com/dashboard/1"; in result
+        assert "http://superset.example.com/chart/list"; in result
+        assert "/redirect?" not in result
+
+
+def test_process_html_links_mixed_urls(app):
+    """Test processing HTML with both internal and external URLs"""
+    with app.app_context():
+        html = """
+        <div>
+            <a href="http://superset.example.com/dashboard/1";>Internal 
Dashboard</a>
+            <a href="https://external.com/page";>External Page</a>
+            <a href="/relative/path">Relative Path</a>
+            <a href="mailto:[email protected]";>Email</a>
+        </div>
+        """
+
+        result = process_html_links(html)
+
+        # Internal link should not be redirected
+        assert "http://superset.example.com/dashboard/1"; in result
+
+        # External link should be redirected
+        assert "/redirect?url=https%3A%2F%2Fexternal.com%2Fpage" in result
+
+        # Relative paths and non-http(s) links should not be modified
+        assert "/relative/path" in result
+        assert "mailto:[email protected]"; in result
+
+
+def test_process_html_links_empty_or_none(app):
+    """Test handling of empty or None HTML content"""
+    with app.app_context():
+        assert process_html_links(None) is None
+        assert process_html_links("") == ""
+        assert process_html_links("   ") == "   "
+
+
+def test_process_html_links_no_links(app):
+    """Test HTML without any links"""
+    with app.app_context():
+        html = "<div><p>This is some text without any links.</p></div>"
+        result = process_html_links(html)
+        assert "This is some text without any links." in result
+        assert "/redirect?" not in result
+
+
+def test_process_html_links_already_redirected(app):
+    """Test that already redirected URLs are not double-redirected"""
+    with app.app_context():
+        html = """
+        <div>
+            <a href="/redirect?url=https%3A%2F%2Fexternal.com">Already 
redirected</a>
+        </div>
+        """
+
+        result = process_html_links(html)
+
+        # Should not double-redirect
+        assert result.count("/redirect?") == 1
+
+
+def test_process_html_links_complex_html(app):
+    """Test processing complex HTML with various elements"""
+    with app.app_context():
+        html = """
+        <table>
+            <tr>
+                <td><a href="https://evil.com";>External</a></td>
+                <td><a 
href="http://superset.example.com/dashboard";>Internal</a></td>
+            </tr>
+        </table>
+        <p>Some text with <a href="https://google.com";>a link</a> in it.</p>
+        <ul>
+            <li><a href="https://github.com";>GitHub</a></li>
+            <li><a href="#anchor">Anchor link</a></li>
+        </ul>
+        """
+
+        result = process_html_links(html)
+
+        # External links should be redirected
+        assert "/redirect?url=https%3A%2F%2Fevil.com" in result
+        assert "/redirect?url=https%3A%2F%2Fgoogle.com" in result
+        assert "/redirect?url=https%3A%2F%2Fgithub.com" in result
+
+        # Internal link should not be redirected
+        assert "http://superset.example.com/dashboard"; in result
+
+        # Anchor link should not be modified
+        assert "#anchor" in result
+
+
+def test_process_html_links_with_custom_base_url(app):
+    """Test processing with a custom base URL"""
+    with app.app_context():
+        html = """
+        <a href="https://mycompany.com/page";>Company Page</a>
+        <a href="https://external.com/page";>External Page</a>
+        """
+
+        result = process_html_links(html, base_url="https://mycompany.com";)
+
+        # mycompany.com link should not be redirected
+        assert "https://mycompany.com/page"; in result
+
+        # external.com link should be redirected
+        assert "/redirect?url=https%3A%2F%2Fexternal.com%2Fpage" in result
+
+
+def test_process_html_links_disable_indicator(app):
+    """Test disabling the external link indicator"""
+    app.config["ALERT_REPORTS_EXTERNAL_LINK_INDICATOR"] = False
+
+    with app.app_context():
+        html = '<a href="https://external.com";>External</a>'
+        result = process_html_links(html)
+
+        # Should have redirect but no external-link class
+        assert "/redirect?url=https%3A%2F%2Fexternal.com" in result
+        assert "external-link" not in result
+
+
+def test_process_html_links_malformed_html(app):
+    """Test that malformed HTML is handled gracefully"""
+    with app.app_context():
+        html = """
+        <div>
+            <a href="https://external.com";>Unclosed link
+            <p>Some text</p>
+        """
+
+        # Should not raise an exception
+        result = process_html_links(html)
+
+        # Should still process the link
+        assert "/redirect?" in result
+
+
+def test_is_safe_redirect_url(app):
+    """Test is_safe_redirect_url function"""
+    with app.app_context():
+        # Internal URLs should be safe
+        assert is_safe_redirect_url("http://superset.example.com/dashboard";)
+        assert is_safe_redirect_url("http://superset.example.com/chart/1";)
+
+        # External URLs should not be safe
+        assert not is_safe_redirect_url("https://evil.com";)
+        assert not is_safe_redirect_url("http://external.site/page";)
+
+        # Empty or None URLs should not be safe
+        assert not is_safe_redirect_url("")
+        assert not is_safe_redirect_url(None)
+
+
+def test_is_safe_redirect_url_with_custom_base(app):
+    """Test is_safe_redirect_url with custom base URL"""
+    with app.app_context():
+        base_url = "https://mycompany.com";
+
+        # mycompany.com URLs should be safe
+        assert is_safe_redirect_url("https://mycompany.com/page";, base_url)
+
+        # Other URLs should not be safe
+        assert not is_safe_redirect_url("https://external.com/page";, base_url)

Reply via email to