This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 2816cb01ab8 Keep the /auth mount path when ProxyFix applies
X-Forwarded-Prefix (#74603)
2816cb01ab8 is described below
commit 2816cb01ab8b8e4897af0df54076fddce6229a49
Author: SHOUNAK SINHA <[email protected]>
AuthorDate: Tue Oct 13 13:27:34 2026 +0530
Keep the /auth mount path when ProxyFix applies X-Forwarded-Prefix (#74603)
The FAB Flask app is mounted under /auth of the API server, so its WSGI
requests arrive with SCRIPT_NAME set to '<base_url path>/auth'. ProxyFix
assigns X-Forwarded-Prefix to SCRIPT_NAME outright, which drops the
/auth segment, and every url_for result on the Security pages (the
Add/Edit/Show links for roles and users) then points at the React shell
instead of the FAB view (#70389).
Wrap ProxyFix so that only the /auth mount segment is taken out of
SCRIPT_NAME beforehand and appended again afterwards. The forwarded
prefix replaces the base_url path, so stashing the whole SCRIPT_NAME
(as #70797 did) doubles the subpath when base_url has a path; stashing
just the mount segment gives '<forwarded prefix>/auth' in both setups.
The mount segment is derived from the constants the API server uses to
mount the auth manager app.
---
.../tests/unit/always/test_project_structure.py | 1 -
.../fab/www/extensions/init_wsgi_middlewares.py | 68 +++++++++++++++--
.../www/extensions/test_init_wsgi_middlewares.py | 89 ++++++++++++++++++++++
3 files changed, 150 insertions(+), 8 deletions(-)
diff --git a/airflow-core/tests/unit/always/test_project_structure.py
b/airflow-core/tests/unit/always/test_project_structure.py
index 8149b3806d6..bf80379664c 100644
--- a/airflow-core/tests/unit/always/test_project_structure.py
+++ b/airflow-core/tests/unit/always/test_project_structure.py
@@ -86,7 +86,6 @@ class TestProjectStructure:
"providers/fab/tests/unit/fab/www/extensions/test_init_manifest_files.py",
"providers/fab/tests/unit/fab/www/extensions/test_init_security.py",
"providers/fab/tests/unit/fab/www/extensions/test_init_views.py",
-
"providers/fab/tests/unit/fab/www/extensions/test_init_wsgi_middlewares.py",
"providers/fab/tests/unit/fab/www/security/test_permissions.py",
"providers/fab/tests/unit/fab/www/test_airflow_flask_app.py",
"providers/fab/tests/unit/fab/www/test_security_appless.py",
diff --git
a/providers/fab/src/airflow/providers/fab/www/extensions/init_wsgi_middlewares.py
b/providers/fab/src/airflow/providers/fab/www/extensions/init_wsgi_middlewares.py
index 48a097f005b..ac10e93e12e 100644
---
a/providers/fab/src/airflow/providers/fab/www/extensions/init_wsgi_middlewares.py
+++
b/providers/fab/src/airflow/providers/fab/www/extensions/init_wsgi_middlewares.py
@@ -1,3 +1,4 @@
+#
# 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
@@ -21,21 +22,74 @@ from typing import TYPE_CHECKING
from werkzeug.middleware.proxy_fix import ProxyFix
+from airflow.api_fastapi.app import API_ROOT_PATH,
AUTH_MANAGER_FASTAPI_APP_PREFIX
from airflow.providers.common.compat.sdk import conf
if TYPE_CHECKING:
+ from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment
from flask import Flask
+_AUTH_MOUNT_PATH_KEY = "airflow.fab.auth_mount_path"
+
+
+def get_auth_mount_path() -> str:
+ """
+ Return the path the API server mounts the auth manager app on, relative to
its root path.
+
+ ``AUTH_MANAGER_FASTAPI_APP_PREFIX`` already includes the ``[api]
base_url`` path, which a
+ reverse proxy replaces with ``X-Forwarded-Prefix``; only the trailing
mount segment must survive.
+ """
+ return
AUTH_MANAGER_FASTAPI_APP_PREFIX.removeprefix(API_ROOT_PATH.removesuffix("/"))
+
+
+class _StashAuthMountPath:
+ """
+ Take the auth manager mount path out of ``SCRIPT_NAME`` before
``ProxyFix`` runs.
+
+ The Flask app is mounted under ``/auth`` of the API server, so WSGI
requests arrive with
+ ``SCRIPT_NAME`` set to ``<base_url path>/auth``. ``ProxyFix`` assigns
``X-Forwarded-Prefix``
+ to ``SCRIPT_NAME`` outright, which drops the ``/auth`` segment and makes
every ``url_for``
+ result point outside the auth manager app. Only the mount segment is
stashed, not the whole
+ ``SCRIPT_NAME``, because the forwarded prefix stands in for the
``base_url`` path.
+ """
+
+ def __init__(self, app: WSGIApplication, mount_path: str) -> None:
+ self.app = app
+ self.mount_path = mount_path
+
+ def __call__(self, environ: WSGIEnvironment, start_response:
StartResponse):
+ script_name = environ.get("SCRIPT_NAME", "")
+ if self.mount_path and script_name.endswith(self.mount_path):
+ environ["SCRIPT_NAME"] = script_name.removesuffix(self.mount_path)
+ environ[_AUTH_MOUNT_PATH_KEY] = self.mount_path
+ return self.app(environ, start_response)
+
+
+class _RestoreAuthMountPath:
+ """Append the stashed mount path to whatever prefix ``ProxyFix`` left in
``SCRIPT_NAME``."""
+
+ def __init__(self, app: WSGIApplication) -> None:
+ self.app = app
+
+ def __call__(self, environ: WSGIEnvironment, start_response:
StartResponse):
+ mount_path = environ.pop(_AUTH_MOUNT_PATH_KEY, "")
+ if mount_path:
+ environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME",
"").rstrip("/") + mount_path
+ return self.app(environ, start_response)
+
def init_wsgi_middleware(flask_app: Flask) -> None:
"""Handle X-Forwarded-* headers and base_url support."""
# Apply ProxyFix middleware
if conf.getboolean("fab", "ENABLE_PROXY_FIX"):
- flask_app.wsgi_app = ProxyFix( # type: ignore
- flask_app.wsgi_app,
- x_for=conf.getint("fab", "PROXY_FIX_X_FOR", fallback=1),
- x_proto=conf.getint("fab", "PROXY_FIX_X_PROTO", fallback=1),
- x_host=conf.getint("fab", "PROXY_FIX_X_HOST", fallback=1),
- x_port=conf.getint("fab", "PROXY_FIX_X_PORT", fallback=1),
- x_prefix=conf.getint("fab", "PROXY_FIX_X_PREFIX", fallback=1),
+ flask_app.wsgi_app = _StashAuthMountPath( # type: ignore
+ ProxyFix(
+ _RestoreAuthMountPath(flask_app.wsgi_app),
+ x_for=conf.getint("fab", "PROXY_FIX_X_FOR", fallback=1),
+ x_proto=conf.getint("fab", "PROXY_FIX_X_PROTO", fallback=1),
+ x_host=conf.getint("fab", "PROXY_FIX_X_HOST", fallback=1),
+ x_port=conf.getint("fab", "PROXY_FIX_X_PORT", fallback=1),
+ x_prefix=conf.getint("fab", "PROXY_FIX_X_PREFIX", fallback=1),
+ ),
+ mount_path=get_auth_mount_path(),
)
diff --git
a/providers/fab/tests/unit/fab/www/extensions/test_init_wsgi_middlewares.py
b/providers/fab/tests/unit/fab/www/extensions/test_init_wsgi_middlewares.py
new file mode 100644
index 00000000000..bbdfceb630c
--- /dev/null
+++ b/providers/fab/tests/unit/fab/www/extensions/test_init_wsgi_middlewares.py
@@ -0,0 +1,89 @@
+#
+# 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 __future__ import annotations
+
+import pytest
+from flask import Flask, url_for
+
+from airflow.providers.fab.www.extensions.init_wsgi_middlewares import (
+ get_auth_mount_path,
+ init_wsgi_middleware,
+)
+
+from tests_common.test_utils.config import conf_vars
+
+
+def build_client(enable_proxy_fix: bool):
+ """Return a test client whose only view reports the URL ``url_for`` builds
for it."""
+ app = Flask(__name__)
+
+ @app.route("/roles/add")
+ def add_role():
+ return url_for("add_role")
+
+ with conf_vars({("fab", "enable_proxy_fix"): str(enable_proxy_fix)}):
+ init_wsgi_middleware(app)
+ return app.test_client()
+
+
+def test_get_auth_mount_path():
+ assert get_auth_mount_path() == "/auth"
+
+
[email protected](
+ ("script_name", "forwarded_prefix", "expected"),
+ [
+ pytest.param("/auth", None, "/auth/roles/add", id="mounted-no-proxy"),
+ pytest.param(
+ "/auth", "/myns/myrelease", "/myns/myrelease/auth/roles/add",
id="mounted-subpath-proxy"
+ ),
+ pytest.param(
+ "/myns/myrelease/auth",
+ "/myns/myrelease",
+ "/myns/myrelease/auth/roles/add",
+ id="base-url-path-and-subpath-proxy",
+ ),
+ pytest.param(
+ "/myns/myrelease/auth", None, "/myns/myrelease/auth/roles/add",
id="base-url-path-no-proxy"
+ ),
+ pytest.param("/auth", "/auth", "/auth/auth/roles/add",
id="proxy-prefix-equals-mount-path"),
+ pytest.param("/auth", "/myns/", "/myns/auth/roles/add",
id="proxy-prefix-trailing-slash"),
+ pytest.param("", "/myns", "/myns/roles/add",
id="not-mounted-subpath-proxy"),
+ pytest.param("", None, "/roles/add", id="not-mounted-no-proxy"),
+ ],
+)
+def test_proxy_fix_keeps_auth_mount_path(script_name, forwarded_prefix,
expected):
+ client = build_client(enable_proxy_fix=True)
+ headers = {"X-Forwarded-Prefix": forwarded_prefix} if forwarded_prefix
else {}
+
+ # The test client derives SCRIPT_NAME from the path of base_url.
+ response = client.get("/roles/add",
base_url=f"http://localhost{script_name}", headers=headers)
+
+ assert response.get_data(as_text=True) == expected
+
+
+def test_proxy_fix_disabled_ignores_forwarded_prefix():
+ client = build_client(enable_proxy_fix=False)
+
+ response = client.get(
+ "/roles/add",
+ base_url="http://localhost/auth",
+ headers={"X-Forwarded-Prefix": "/myns/myrelease"},
+ )
+
+ assert response.get_data(as_text=True) == "/auth/roles/add"