You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by be...@apache.org on 2025/09/04 15:45:50 UTC
(superset) 01/03: feat(alerts/reports): external URL warning
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 <ro...@dealmeida.net>
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
+
+
+@pytest.fixture
+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:user@example.com">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:user@example.com" 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)