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 b3bcf9f89dd Support a regex for the API's CORS allowed origins (#71778)
b3bcf9f89dd is described below

commit b3bcf9f89dde903e07449c8d9aac083a02d73156
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Mon Sep 7 16:05:27 2026 +0200

    Support a regex for the API's CORS allowed origins (#71778)
    
    Deployments whose API clients run on dynamic hostnames — per-branch preview
    environments, ephemeral subdomains — cannot enumerate every origin ahead of
    time, and the wildcard is rejected because Airflow's API requires 
credentialed
    CORS. FastAPI's CORS middleware already accepts a regex that echoes the 
matched
    origin back rather than `*`, which stays compatible with credentialed
    responses, so expose it as an optional config option.
    
    closes: #52155
---
 airflow-core/docs/security/api.rst                 | 11 +++++++++++
 .../src/airflow/api_fastapi/core_api/app.py        |  4 +++-
 .../src/airflow/config_templates/config.yml        | 11 +++++++++++
 .../tests/unit/api_fastapi/core_api/test_app.py    | 22 ++++++++++++++++++++++
 4 files changed, 47 insertions(+), 1 deletion(-)

diff --git a/airflow-core/docs/security/api.rst 
b/airflow-core/docs/security/api.rst
index cd626661c9b..568757796fa 100644
--- a/airflow-core/docs/security/api.rst
+++ b/airflow-core/docs/security/api.rst
@@ -104,6 +104,17 @@ clients can send cookies and ``Authorization`` headers 
across origins. Because o
 ``Access-Control-Allow-Origin: *`` with credentialed responses, and browsers 
refuse any
 response that does so, so a wildcard origin would simply break every 
cross-origin request.
 
+Origins can also be matched with a regular expression via 
``access_control_allow_origin_regex``,
+which is useful when the allowed origins are dynamic (for example per-branch 
preview deployments):
+
+.. code-block:: ini
+
+    [api]
+    access_control_allow_origin_regex = https://.*\.mycompany\.com
+
+The regex is matched against the request ``Origin`` and, on a match, that 
exact origin is echoed
+back (never ``*``), so it stays compatible with the credentialed responses 
described above.
+
 Page size limit
 ---------------
 
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 961f13fbc71..8ceea05828b 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -143,6 +143,7 @@ def init_config(app: FastAPI) -> None:
     allow_origins = conf.getlist("api", "access_control_allow_origins")
     allow_methods = conf.getlist("api", "access_control_allow_methods")
     allow_headers = conf.getlist("api", "access_control_allow_headers")
+    allow_origin_regex = conf.get("api", "access_control_allow_origin_regex", 
fallback="") or None
 
     if "*" in allow_origins:
         # The CORS spec forbids combining `Access-Control-Allow-Origin: *` with
@@ -158,10 +159,11 @@ def init_config(app: FastAPI) -> None:
             "(e.g. `https://airflow.mycompany.com`) instead."
         )
 
-    if allow_origins or allow_methods or allow_headers:
+    if allow_origins or allow_methods or allow_headers or allow_origin_regex:
         app.add_middleware(
             CORSMiddleware,
             allow_origins=allow_origins,
+            allow_origin_regex=allow_origin_regex,
             allow_credentials=True,
             allow_methods=allow_methods,
             allow_headers=allow_headers,
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index eac6b83174f..444f85a2b7a 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -1947,6 +1947,17 @@ api:
       version_added: 2.2.0
       example: ~
       default: ""
+    access_control_allow_origin_regex:
+      description: |
+        A regex matched against the request ``Origin`` to decide whether the 
response can be shared,
+        as an alternative to listing exact origins in 
``access_control_allow_origins``. Useful when
+        the allowed origins are dynamic (for example per-branch preview 
deployments). The matched
+        origin is echoed back rather than ``*``, so it stays compatible with 
the credentialed CORS
+        Airflow's API requires. Example: ``https://.*\\.mycompany\\.com``.
+      type: string
+      version_added: 3.4.0
+      example: ~
+      default: ""
     grid_view_sorting_order:
       description: |
         Sorting order in grid view. Valid values are: ``topological``, 
``hierarchical_alphabetical``
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py 
b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
index 9e3c7219f03..0cabcf27393 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
@@ -26,6 +26,7 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.params import Depends as DependsClass
 from fastapi.responses import StreamingResponse
 from starlette.routing import Mount
+from starlette.testclient import TestClient
 
 from airflow.api_fastapi.app import create_app
 from airflow.api_fastapi.core_api.app import init_config
@@ -177,3 +178,24 @@ class TestCorsMiddlewareConfig:
             app = FastAPI()
             with pytest.raises(AirflowConfigException, match=r"must not 
contain `\*`"):
                 init_config(app)
+
+    def test_allow_origin_regex_allows_matching_and_blocks_other_origins(self):
+        """A request whose Origin matches the regex is CORS-approved; a 
non-matching one is not."""
+        with conf_vars({("api", "access_control_allow_origin_regex"): 
r"https://.*\.example\.com"}):
+            app = FastAPI()
+            init_config(app)
+
+        @app.get("/ping")
+        def ping():
+            return {"ok": True}
+
+        client = TestClient(app)
+
+        allowed = client.get("/ping", headers={"Origin": 
"https://app.example.com"})
+        assert allowed.status_code == 200
+        assert allowed.headers["access-control-allow-origin"] == 
"https://app.example.com";
+        assert allowed.headers["access-control-allow-credentials"] == "true"
+
+        blocked = client.get("/ping", headers={"Origin": "https://evil.com"})
+        assert blocked.status_code == 200
+        assert "access-control-allow-origin" not in blocked.headers

Reply via email to