diff --git a/flask_appbuilder/utils/base.py b/flask_appbuilder/utils/base.py index cbf284c364..406f157887 100644 --- a/flask_appbuilder/utils/base.py +++ b/flask_appbuilder/utils/base.py @@ -1,3 +1,4 @@ +from fnmatch import fnmatch import logging from typing import Any, Callable import unicodedata @@ -27,11 +28,17 @@ def is_safe_redirect_url(url: str) -> bool: if not url_info.scheme and url_info.netloc: scheme = "http" valid_schemes = ["http", "https"] - host_url = urlparse(request.host_url) - return (not url_info.netloc or url_info.netloc == host_url.netloc) and ( - not scheme or scheme in valid_schemes + + safe_hosts = current_app.config.get("SAFE_REDIRECT_HOSTS", []) + if not safe_hosts: + safe_hosts = [urlparse(request.host_url).netloc] + + is_host_allowed = not url_info.netloc or any( + fnmatch(url_info.netloc, pattern) for pattern in safe_hosts ) + return is_host_allowed and (not scheme or scheme in valid_schemes) + def get_safe_redirect(url): if url and is_safe_redirect_url(url): diff --git a/tests/base.py b/tests/base.py index 6c68ed062a..8060e06466 100644 --- a/tests/base.py +++ b/tests/base.py @@ -75,6 +75,7 @@ def browser_login( password: str, next_url: Optional[str] = None, follow_redirects: bool = True, + headers: Optional[dict] = None, ) -> Response: login_url = "/login/" if next_url: @@ -83,6 +84,7 @@ def browser_login( login_url, data=dict(username=username, password=password), follow_redirects=follow_redirects, + headers=headers or {}, ) def assert_response( diff --git a/tests/security/test_mvc_security.py b/tests/security/test_mvc_security.py index 05925d8a97..d2fec430a9 100644 --- a/tests/security/test_mvc_security.py +++ b/tests/security/test_mvc_security.py @@ -245,6 +245,81 @@ def test_db_login_invalid_no_netloc_with_scheme_next_url(self): ) assert response.location == "/" + def test_login_next_url_spoofed_host_header_disallowed(self): + """ + Ensure a spoofed Host header does not allow redirection to an untrusted domain + """ + self.app.config["SAFE_REDIRECT_HOSTS"] = ["localhost"] # trusted dev host + self.browser_logout(self.client) + + response = self.browser_login( + self.client, + USERNAME_ADMIN, + PASSWORD_ADMIN, + next_url="https://example.com/", + follow_redirects=False, + headers={"Host": "example.com"}, + ) + + assert response.status_code == 302 + assert response.location == "/" + + def test_login_next_url_spoofed_host_header_allowed_config(self): + """ + Ensure a spoofed Host header does not allow redirection to an untrusted domain + """ + self.app.config["SAFE_REDIRECT_HOSTS"] = ["localhost"] # trusted dev host + self.browser_logout(self.client) + + response = self.browser_login( + self.client, + USERNAME_ADMIN, + PASSWORD_ADMIN, + next_url="https://localhost/something", + follow_redirects=False, + headers={"Host": "example.com"}, + ) + + assert response.status_code == 302 + assert response.location == "https://localhost/something" + + def test_login_next_url_allowed_config_wildcard(self): + """ + Ensure a spoofed Host header does not allow redirection to an untrusted domain + """ + self.app.config["SAFE_REDIRECT_HOSTS"] = ["*.localhost"] # trusted dev host + self.browser_logout(self.client) + + response = self.browser_login( + self.client, + USERNAME_ADMIN, + PASSWORD_ADMIN, + next_url="https://example.localhost/something", + follow_redirects=False, + headers={"Host": "example.localhost"}, + ) + + assert response.status_code == 302 + assert response.location == "https://example.localhost/something" + + def test_login_next_url_spoofed_host_header_allowed(self): + """ + Ensure a spoofed Host header does not allow redirection to an untrusted domain + """ + self.browser_logout(self.client) + + response = self.browser_login( + self.client, + USERNAME_ADMIN, + PASSWORD_ADMIN, + next_url="https://example.com/", + follow_redirects=False, + headers={"Host": "example.com"}, + ) + + assert response.status_code == 302 + assert response.location == "https://example.com/" + def test_db_login_invalid_control_characters_next_url(self): """ Test Security invalid next URL with control characters