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)
