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"

Reply via email to