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

pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new bfd1881fa0c AIP-84 Add safe url helper method (#47577)
bfd1881fa0c is described below

commit bfd1881fa0cb1193f49dbac72d7462e66aaf8f25
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Tue Mar 11 16:13:12 2025 +0100

    AIP-84 Add safe url helper method (#47577)
    
    * AIP-84 Add safe url helper method
    
    * Following code review
---
 .../api_fastapi/core_api/routes/public/login.py    |  6 ++++-
 airflow/api_fastapi/core_api/security.py           | 22 ++++++++++++++++++
 .../core_api/routes/public/test_login.py           | 21 ++++++++++++++---
 tests/api_fastapi/core_api/test_security.py        | 27 +++++++++++++++++++++-
 4 files changed, 71 insertions(+), 5 deletions(-)

diff --git a/airflow/api_fastapi/core_api/routes/public/login.py 
b/airflow/api_fastapi/core_api/routes/public/login.py
index 23c6cd4e19b..75ef49c7c4f 100644
--- a/airflow/api_fastapi/core_api/routes/public/login.py
+++ b/airflow/api_fastapi/core_api/routes/public/login.py
@@ -16,11 +16,12 @@
 # under the License.
 from __future__ import annotations
 
-from fastapi import Request, status
+from fastapi import HTTPException, Request, status
 from fastapi.responses import RedirectResponse
 
 from airflow.api_fastapi.common.router import AirflowRouter
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
+from airflow.api_fastapi.core_api.security import is_safe_url
 
 login_router = AirflowRouter(tags=["Login"], prefix="/login")
 
@@ -33,6 +34,9 @@ def login(request: Request, next: None | str = None) -> 
RedirectResponse:
     """Redirect to the login URL depending on the AuthManager configured."""
     login_url = request.app.state.auth_manager.get_url_login()
 
+    if next and not is_safe_url(next):
+        raise HTTPException(status_code=400, detail="Invalid or unsafe next 
URL")
+
     if next:
         login_url += f"?next={next}"
     return RedirectResponse(login_url)
diff --git a/airflow/api_fastapi/core_api/security.py 
b/airflow/api_fastapi/core_api/security.py
index 7ef10be39e3..3d40f785234 100644
--- a/airflow/api_fastapi/core_api/security.py
+++ b/airflow/api_fastapi/core_api/security.py
@@ -17,7 +17,9 @@
 from __future__ import annotations
 
 from functools import cache
+from pathlib import Path
 from typing import TYPE_CHECKING, Annotated, Callable
+from urllib.parse import urljoin, urlparse
 
 from fastapi import Depends, HTTPException, Request, status
 from fastapi.security import OAuth2PasswordBearer
@@ -253,3 +255,23 @@ def _requires_access(
 ) -> None:
     if not is_authorized_callback():
         raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden")
+
+
+def is_safe_url(target_url: str) -> bool:
+    """
+    Check that the URL is safe.
+
+    Needs to belong to the same domain as base_url, use HTTP or HTTPS (no 
JavaScript/data schemes),
+    is a valid normalized path.
+    """
+    base_url = conf.get("api", "base_url")
+
+    parsed_base = urlparse(base_url)
+    parsed_target = urlparse(urljoin(base_url, target_url))  # Resolves 
relative URLs
+
+    target_path = Path(parsed_target.path).resolve()
+
+    if target_path and parsed_base.path and not 
target_path.is_relative_to(parsed_base.path):
+        return False
+
+    return parsed_target.scheme in {"http", "https"} and parsed_target.netloc 
== parsed_base.netloc
diff --git a/tests/api_fastapi/core_api/routes/public/test_login.py 
b/tests/api_fastapi/core_api/routes/public/test_login.py
index dc00d673b30..f3e407db0e9 100644
--- a/tests/api_fastapi/core_api/routes/public/test_login.py
+++ b/tests/api_fastapi/core_api/routes/public/test_login.py
@@ -20,6 +20,8 @@ from unittest.mock import MagicMock
 
 import pytest
 
+from tests_common.test_utils.config import conf_vars
+
 AUTH_MANAGER_LOGIN_URL = "http://some_login_url";
 
 pytestmark = pytest.mark.db_test
@@ -39,11 +41,11 @@ class TestGetLogin(TestLoginEndpoint):
         [
             {},
             {"next": None},
-            {"next": "http://localhost:28080"},
-            {"next": "http://localhost:28080";, "other_param": 
"something_else"},
+            {"next": "http://localhost:8080"},
+            {"next": "http://localhost:8080";, "other_param": "something_else"},
         ],
     )
-    def test_should_respond_308(self, test_client, params):
+    def test_should_respond_307(self, test_client, params):
         response = test_client.get("/public/login", follow_redirects=False, 
params=params)
 
         assert response.status_code == 307
@@ -52,3 +54,16 @@ class TestGetLogin(TestLoginEndpoint):
             if params.get("next")
             else AUTH_MANAGER_LOGIN_URL
         )
+
+    @pytest.mark.parametrize(
+        "params",
+        [
+            {"next": "http://fake_domain.com:8080"},
+            {"next": "http://localhost:8080/../../up"},
+        ],
+    )
+    @conf_vars({("api", "base_url"): "http://localhost:8080/prefix"})
+    def test_should_respond_400(self, test_client, params):
+        response = test_client.get("/public/login", follow_redirects=False, 
params=params)
+
+        assert response.status_code == 400
diff --git a/tests/api_fastapi/core_api/test_security.py 
b/tests/api_fastapi/core_api/test_security.py
index 193cc67798f..84328e650f7 100644
--- a/tests/api_fastapi/core_api/test_security.py
+++ b/tests/api_fastapi/core_api/test_security.py
@@ -25,7 +25,7 @@ from jwt import ExpiredSignatureError, InvalidTokenError
 from airflow.api_fastapi.app import create_app
 from airflow.api_fastapi.auth.managers.models.resource_details import 
DagAccessEntity
 from airflow.api_fastapi.auth.managers.simple.user import SimpleAuthManagerUser
-from airflow.api_fastapi.core_api.security import get_user, requires_access_dag
+from airflow.api_fastapi.core_api.security import get_user, is_safe_url, 
requires_access_dag
 
 from tests_common.test_utils.config import conf_vars
 
@@ -110,3 +110,28 @@ class TestFastApiSecurity:
             requires_access_dag("GET", DagAccessEntity.CODE)(fastapi_request, 
Mock())
 
         auth_manager.is_authorized_dag.assert_called_once()
+
+    @pytest.mark.parametrize(
+        "url, expected_is_safe",
+        [
+            ("https://server_base_url.com/prefix/some_page?with_param=3";, 
True),
+            ("https://server_base_url.com/prefix/";, True),
+            ("https://server_base_url.com/prefix";, True),
+            ("/prefix/some_other", True),
+            ("prefix/some_other", True),
+            # Relative path, will go up one level escaping the prefix folder
+            ("some_other", False),
+            ("./some_other", False),
+            # wrong scheme
+            ("javascript://server_base_url.com/prefix/some_page?with_param=3", 
False),
+            # wrong netloc
+            ("https://some_netlock.com/prefix/some_page?with_param=3";, False),
+            # Absolute path escaping the prefix folder
+            ("/some_other_page/", False),
+            # traversal, escaping the `prefix` folder
+            ("/../../../../some_page?with_param=3", False),
+        ],
+    )
+    @conf_vars({("api", "base_url"): "https://server_base_url.com/prefix"})
+    def test_is_safe_url(self, url, expected_is_safe):
+        assert is_safe_url(url) == expected_is_safe

Reply via email to