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