diff --git a/airflow-core/src/airflow/api_fastapi/app.py b/airflow-core/src/airflow/api_fastapi/app.py index 3f4956660ddc1..6d550839bd365 100644 --- a/airflow-core/src/airflow/api_fastapi/app.py +++ b/airflow-core/src/airflow/api_fastapi/app.py @@ -17,7 +17,6 @@ from __future__ import annotations import logging -import os from contextlib import AsyncExitStack, asynccontextmanager from typing import TYPE_CHECKING, cast from urllib.parse import urlsplit @@ -41,10 +40,9 @@ if TYPE_CHECKING: from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager -API_BASE_URL = conf.get("api", "base_url") -if API_BASE_URL and not API_BASE_URL.endswith("/"): +API_BASE_URL = conf.get("api", "base_url", fallback="") +if not API_BASE_URL or not API_BASE_URL.endswith("/"): API_BASE_URL += "/" - os.environ["AIRFLOW__API__BASE_URL"] = API_BASE_URL API_ROOT_PATH = urlsplit(API_BASE_URL).path # Define the full path on which the potential auth manager fastapi is mounted diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py index 10cd632f6db71..c901692d8f9c8 100644 --- a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py +++ b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py @@ -59,7 +59,7 @@ def create_token_all_admins() -> LoginResponse: ) def login_all_admins() -> RedirectResponse: """Login the user with no credentials.""" - response = RedirectResponse(url=conf.get("api", "base_url")) + response = RedirectResponse(url=conf.get("api", "base_url", fallback="/")) secure = conf.has_option("api", "ssl_cert") response.set_cookie( COOKIE_NAME_JWT_TOKEN, diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py index e25000a0aee20..af3a42db333a7 100644 --- a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py +++ b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py @@ -308,7 +308,7 @@ def get_fastapi_app(self) -> FastAPI | None: def webapp(request: Request, rest_of_path: str): return templates.TemplateResponse( "/index.html", - {"request": request, "backend_server_base_url": conf.get("api", "base_url")}, + {"request": request, "backend_server_base_url": str(request.base_url)}, media_type="text/html", ) diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/router.tsx b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/router.tsx index b78ee5470788f..69a951f72621e 100644 --- a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/router.tsx +++ b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/router.tsx @@ -31,7 +31,13 @@ export const routerConfig = [ path: "/", }, ]; -const baseUrl = document.querySelector("base")?.href ?? "http://localhost:8080/"; + +const baseHref = document.querySelector("head>base")?.getAttribute("href"); +const baseUrl = + baseHref !== null && baseHref !== undefined && baseHref !== "" + ? baseHref + : `${globalThis.location.origin}/`; + const basename = new URL(`${baseUrl}auth`).pathname; export const router = createBrowserRouter(routerConfig, { basename }); diff --git a/airflow-core/src/airflow/api_fastapi/core_api/app.py b/airflow-core/src/airflow/api_fastapi/core_api/app.py index 22ac4aba56e1a..371a4af7f50ef 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/app.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py @@ -32,7 +32,6 @@ from airflow.api_fastapi.auth.tokens import get_signing_key from airflow.api_fastapi.core_api.init_dagbag import get_dag_bag from airflow.api_fastapi.core_api.middleware import FlaskExceptionsMiddleware -from airflow.configuration import conf from airflow.exceptions import AirflowException from airflow.settings import AIRFLOW_PATH @@ -79,7 +78,7 @@ def init_views(app: FastAPI) -> None: def webapp(request: Request, rest_of_path: str): return templates.TemplateResponse( "/index.html", - {"request": request, "backend_server_base_url": conf.get("api", "base_url")}, + {"request": request, "backend_server_base_url": str(request.base_url)}, media_type="text/html", ) diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py index cd1f87d766c71..b8f6d204d2ed1 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py @@ -34,11 +34,12 @@ 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): + if next and not is_safe_url(next, request=request): 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-core/src/airflow/api_fastapi/core_api/security.py b/airflow-core/src/airflow/api_fastapi/core_api/security.py index 0b5f51d2c81ec..e57a6543faf46 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/security.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/security.py @@ -18,7 +18,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Annotated, Callable -from urllib.parse import urljoin, urlparse +from urllib.parse import ParseResult, urljoin, urlparse from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer @@ -329,21 +329,34 @@ def _requires_access( raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden") -def is_safe_url(target_url: str) -> bool: +def is_safe_url(target_url: str, request: Request | None = None) -> 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_bases: tuple[tuple[str, ParseResult], ...] = () - parsed_base = urlparse(base_url) - parsed_target = urlparse(urljoin(base_url, target_url)) # Resolves relative URLs + # Check if the target URL matches either the configured base URL, or the URL used to make the request + if request is not None: + url = str(request.base_url) + parsed_bases += ((url, urlparse(url)),) + if base_url := conf.get("api", "base_url", fallback=None): + parsed_bases += ((base_url, urlparse(base_url)),) - target_path = Path(parsed_target.path).resolve() + if not parsed_bases: + # Can't enforce any security check. + return True - if target_path and parsed_base.path and not target_path.is_relative_to(parsed_base.path): - return False + for base_url, parsed_base in parsed_bases: + parsed_target = urlparse(urljoin(base_url, target_url)) # Resolves relative URLs - return parsed_target.scheme in {"http", "https"} and parsed_target.netloc == parsed_base.netloc + target_path = Path(parsed_target.path).resolve() + + if target_path and parsed_base.path and not target_path.is_relative_to(parsed_base.path): + continue + + if parsed_target.scheme in {"http", "https"} and parsed_target.netloc == parsed_base.netloc: + return True + return False diff --git a/airflow-core/src/airflow/config_templates/config.yml b/airflow-core/src/airflow/config_templates/config.yml index 813316bef0937..b4a2ebc1de759 100644 --- a/airflow-core/src/airflow/config_templates/config.yml +++ b/airflow-core/src/airflow/config_templates/config.yml @@ -1308,8 +1308,8 @@ api: should contain the API server endpoint. version_added: ~ type: string - example: ~ - default: "http://localhost:8080" + example: "https://my-airflow.company.com" + default: ~ host: description: | The ip specified when starting the api server diff --git a/airflow-core/src/airflow/models/taskinstance.py b/airflow-core/src/airflow/models/taskinstance.py index e35927df61e34..3277a5ed34d03 100644 --- a/airflow-core/src/airflow/models/taskinstance.py +++ b/airflow-core/src/airflow/models/taskinstance.py @@ -1915,7 +1915,7 @@ def generate_command( def log_url(self) -> str: """Log URL for TaskInstance.""" run_id = quote(self.run_id) - base_url = conf.get_mandatory_value("api", "BASE_URL") + base_url = conf.get("api", "base_url", fallback="http://localhost:8080/") map_index = f"/mapped/{self.map_index}" if self.map_index >= 0 else "" try_number = f"?try_number={self.try_number}" if self.try_number > 0 else "" _log_uri = f"{base_url}dags/{self.dag_id}/runs/{run_id}/tasks/{self.task_id}{map_index}{try_number}" diff --git a/airflow-core/src/airflow/ui/src/main.tsx b/airflow-core/src/airflow/ui/src/main.tsx index 603e914fae3e5..92bb4a23f4e05 100644 --- a/airflow-core/src/airflow/ui/src/main.tsx +++ b/airflow-core/src/airflow/ui/src/main.tsx @@ -44,7 +44,13 @@ axios.interceptors.response.use( const params = new URLSearchParams(); params.set("next", globalThis.location.href); - const baseUrl = document.querySelector("head>base")?.getAttribute("href") ?? ""; + + const baseHref = document.querySelector("head>base")?.getAttribute("href"); + const baseUrl = + baseHref !== null && baseHref !== undefined && baseHref !== "" + ? baseHref + : `${globalThis.location.origin}/`; + const loginPath = new URL("api/v2/auth/login", baseUrl).pathname; globalThis.location.replace(`${loginPath}?${params.toString()}`); diff --git a/airflow-core/src/airflow/utils/helpers.py b/airflow-core/src/airflow/utils/helpers.py index 601373dd32a4c..665793c89baf0 100644 --- a/airflow-core/src/airflow/utils/helpers.py +++ b/airflow-core/src/airflow/utils/helpers.py @@ -205,7 +205,7 @@ def build_airflow_dagrun_url(dag_id: str, run_id: str) -> str: For example: http://localhost:8080/dags/hi/runs/manual__2025-02-23T18:27:39.051358+00:00_RZa1at4Q """ - baseurl = conf.get("api", "base_url") + baseurl = conf.get("api", "base_url", fallback="/") return urljoin(baseurl, f"dags/{dag_id}/runs/{run_id}") diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py index d379054ac1b0f..121c3b0d48ae2 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py @@ -16,7 +16,7 @@ # under the License. from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -45,7 +45,8 @@ class TestGetLogin(TestAuthEndpoint): {"next": "http://localhost:8080", "other_param": "something_else"}, ], ) - def test_should_respond_307(self, test_client, params): + @patch("airflow.api_fastapi.core_api.routes.public.auth.is_safe_url", return_value=True) + def test_should_respond_307(self, mock_is_safe_url, test_client, params): response = test_client.get("/auth/login", follow_redirects=False, params=params) assert response.status_code == 307 diff --git a/airflow-core/tests/unit/api_fastapi/core_api/test_security.py b/airflow-core/tests/unit/api_fastapi/core_api/test_security.py index f58f67cf4afa4..0087f29a505e2 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/test_security.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/test_security.py @@ -120,6 +120,7 @@ async def test_requires_access_dag_unauthorized(self, mock_get_auth_manager): ("https://server_base_url.com/prefix", True), ("/prefix/some_other", True), ("prefix/some_other", True), + ("https://requesting_server_base_url.com/prefix2", True), # safe in regards to the request url # Relative path, will go up one level escaping the prefix folder ("some_other", False), ("./some_other", False), @@ -135,4 +136,6 @@ async def test_requires_access_dag_unauthorized(self, mock_get_auth_manager): ) @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 + request = Mock() + request.base_url = "https://requesting_server_base_url.com/prefix2" + assert is_safe_url(url, request=request) == expected_is_safe diff --git a/airflow-core/tests/unit/utils/test_helpers.py b/airflow-core/tests/unit/utils/test_helpers.py index 401f0c48fcfe4..4e11be96fc9c3 100644 --- a/airflow-core/tests/unit/utils/test_helpers.py +++ b/airflow-core/tests/unit/utils/test_helpers.py @@ -131,7 +131,7 @@ def test_merge_dicts_recursive_right_only(self): assert merged == {"a": 1, "r": {"b": 0, "c": 3}} def test_build_airflow_dagrun_url(self): - expected_url = "http://localhost:8080/dags/somedag/runs/abc123" + expected_url = "/dags/somedag/runs/abc123" assert build_airflow_dagrun_url(dag_id="somedag", run_id="abc123") == expected_url @pytest.mark.parametrize( diff --git a/dev/breeze/src/airflow_breeze/params/shell_params.py b/dev/breeze/src/airflow_breeze/params/shell_params.py index 05be0a8471acf..efec7c121043d 100644 --- a/dev/breeze/src/airflow_breeze/params/shell_params.py +++ b/dev/breeze/src/airflow_breeze/params/shell_params.py @@ -519,7 +519,6 @@ def env_variables_for_docker_commands(self) -> dict[str, str]: _set_var(_env, "AIRFLOW_IMAGE_KUBERNETES", self.airflow_image_kubernetes) _set_var(_env, "AIRFLOW_VERSION", self.airflow_version) _set_var(_env, "AIRFLOW__API_AUTH__JWT_SECRET", b64encode(os.urandom(16)).decode("utf-8")) - _set_var(_env, "AIRFLOW__API__BASE_URL", f"http://localhost:{WEB_HOST_PORT}") _set_var(_env, "AIRFLOW__CELERY__BROKER_URL", self.airflow_celery_broker_url) _set_var(_env, "AIRFLOW__CORE__AUTH_MANAGER", self.auth_manager_path) _set_var(_env, "AIRFLOW__CORE__EXECUTOR", self.executor) diff --git a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/aws_auth_manager.py b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/aws_auth_manager.py index c15452a758429..542ab209455db 100644 --- a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/aws_auth_manager.py +++ b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/aws_auth_manager.py @@ -87,7 +87,7 @@ def avp_facade(self): @cached_property def apiserver_endpoint(self) -> str: - return conf.get("api", "base_url") + return conf.get("api", "base_url", fallback="/") def deserialize_user(self, token: dict[str, Any]) -> AwsAuthManagerUser: return AwsAuthManagerUser(user_id=token.pop("sub"), **token) diff --git a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/router/login.py b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/router/login.py index 68e0e3ddc0bb2..918451d425b4a 100644 --- a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/router/login.py +++ b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/router/login.py @@ -80,7 +80,7 @@ def login_callback(request: Request): username=saml_auth.get_nameid(), email=attributes["email"][0] if "email" in attributes else None, ) - url = conf.get("api", "base_url") + url = conf.get("api", "base_url", fallback="/") token = get_auth_manager().generate_jwt(user) response = RedirectResponse(url=url, status_code=303) diff --git a/providers/amazon/tests/unit/amazon/aws/auth_manager/test_aws_auth_manager.py b/providers/amazon/tests/unit/amazon/aws/auth_manager/test_aws_auth_manager.py index 42a848361a28f..fc469fc223820 100644 --- a/providers/amazon/tests/unit/amazon/aws/auth_manager/test_aws_auth_manager.py +++ b/providers/amazon/tests/unit/amazon/aws/auth_manager/test_aws_auth_manager.py @@ -689,7 +689,7 @@ def test_filter_authorized_dag_ids(self, method, user, auth_manager, test_user, def test_get_url_login(self, auth_manager): result = auth_manager.get_url_login() - assert result == f"http://localhost:8080{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login" + assert result == f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login" def test_get_cli_commands_return_cli_commands(self, auth_manager): assert len(auth_manager.get_cli_commands()) > 0 diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py index f801eb9cdbde6..1f96a7d7e23fd 100644 --- a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py +++ b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py @@ -181,7 +181,7 @@ def init_flask_resources(self) -> None: @cached_property def apiserver_endpoint(self) -> str: - return conf.get("api", "base_url") + return conf.get("api", "base_url", fallback="/") @staticmethod def get_cli_commands() -> list[CLICommand]: diff --git a/providers/fab/src/airflow/providers/fab/www/views.py b/providers/fab/src/airflow/providers/fab/www/views.py index f4e22508c5fd5..9279b4e42678c 100644 --- a/providers/fab/src/airflow/providers/fab/www/views.py +++ b/providers/fab/src/airflow/providers/fab/www/views.py @@ -75,7 +75,7 @@ def index(self): return response else: - return redirect(conf.get("api", "base_url"), code=302) + return redirect(conf.get("api", "base_url", fallback="/"), code=302) def show_traceback(error): diff --git a/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py b/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py index 9f2057e339c5f..62a4eb420acc1 100644 --- a/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py +++ b/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py @@ -618,11 +618,11 @@ class TestSecurityManager: def test_get_url_login(self, auth_manager): result = auth_manager.get_url_login() - assert result == f"http://localhost:8080{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login/" + assert result == f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login/" def test_get_url_logout(self, auth_manager): result = auth_manager.get_url_logout() - assert result == f"http://localhost:8080{AUTH_MANAGER_FASTAPI_APP_PREFIX}/logout/" + assert result == f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/logout/" @mock.patch.object(FabAuthManager, "_is_authorized", return_value=True) def test_get_extra_menu_items(self, _, auth_manager_with_appbuilder, flask_app): diff --git a/providers/standard/tests/unit/standard/operators/test_trigger_dagrun.py b/providers/standard/tests/unit/standard/operators/test_trigger_dagrun.py index b82b9e0929ee2..30513d470082c 100644 --- a/providers/standard/tests/unit/standard/operators/test_trigger_dagrun.py +++ b/providers/standard/tests/unit/standard/operators/test_trigger_dagrun.py @@ -139,7 +139,7 @@ def test_extra_operator_link(self, mock_xcom_get_one, dag_maker): link = task.operator_extra_links[0].get_link(operator=task, ti_key=ti.key) - base_url = conf.get_mandatory_value("api", "base_url").lower() + base_url = conf.get("api", "base_url", fallback="/").lower() expected_url = f"{base_url}dags/{TRIGGERED_DAG_ID}/runs/test_run_id" assert link == expected_url, f"Expected {expected_url}, but got {link}"