This is an automated email from the ASF dual-hosted git repository.
potiuk 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 70257e69686 Reject wildcard origin in CORS config instead of toggling
credentials (#67502)
70257e69686 is described below
commit 70257e6968603cdfcb5d34f03e01c5c001d2075f
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed May 27 23:57:15 2026 +0200
Reject wildcard origin in CORS config instead of toggling credentials
(#67502)
The Access-Control-Allow-Origin: * + Access-Control-Allow-Credentials: true
combination is invalid per the CORS spec and browsers refuse to honour any
response that does so. The previous fix (#66503) added an
access_control_allow_credentials toggle, but allow_credentials=False would
break Airflow's UI on any deployment where API and UI are on different
origins, so that knob has no realistic use case.
Drop the toggle, always send credentialed CORS, and fail loudly at startup
with AirflowConfigException if access_control_allow_origins contains "*"
so operators see the bad configuration immediately instead of debugging
mysterious CORS errors in the browser.
Closes #67193 (the revert is no longer needed once the underlying
misconfiguration is rejected directly).
---
airflow-core/docs/security/api.rst | 10 ++++---
.../src/airflow/api_fastapi/core_api/app.py | 19 ++++++++++--
.../src/airflow/config_templates/config.yml | 15 +++-------
.../tests/unit/api_fastapi/core_api/test_app.py | 35 ++++++++++++++--------
4 files changed, 49 insertions(+), 30 deletions(-)
diff --git a/airflow-core/docs/security/api.rst
b/airflow-core/docs/security/api.rst
index c02033d0b12..b45616ece89 100644
--- a/airflow-core/docs/security/api.rst
+++ b/airflow-core/docs/security/api.rst
@@ -86,10 +86,12 @@ from scripts running in the browser.
access_control_allow_methods = POST, GET, OPTIONS, DELETE
access_control_allow_origins = https://exampleclientapp1.com
https://exampleclientapp2.com
-The ``Access-Control-Allow-Credentials`` header is included by default. Set
-``access_control_allow_credentials = False`` if you have configured
-``access_control_allow_origins`` and do not want browsers to send credentials
-(cookies, ``Authorization`` header) with cross-origin requests.
+Airflow's API always responds with ``Access-Control-Allow-Credentials: true``
so the UI and
+clients can send cookies and ``Authorization`` headers across origins. Because
of that,
+``access_control_allow_origins`` must list the exact origins that need access
— the wildcard
+``*`` is rejected at startup. The CORS spec forbids combining
+``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.
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 20ecbedb236..27213a4c0a2 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -29,7 +29,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from airflow.api_fastapi.auth.tokens import get_signing_key
-from airflow.exceptions import AirflowException
+from airflow.exceptions import AirflowConfigException, AirflowException
log = logging.getLogger(__name__)
@@ -143,13 +143,26 @@ 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_credentials = conf.getboolean("api",
"access_control_allow_credentials", fallback=True)
+
+ if "*" in allow_origins:
+ # The CORS spec forbids combining `Access-Control-Allow-Origin: *` with
+ # `Access-Control-Allow-Credentials: true`, and browsers reject any
response that does so
+ # (see https://fetch.spec.whatwg.org/#cors-protocol-and-credentials).
Airflow's API needs
+ # credentialed requests for cookie / Authorization-header auth, so a
wildcard origin is
+ # never a valid configuration. Fail loudly at startup instead of
silently shipping a
+ # response shape that no browser will accept.
+ raise AirflowConfigException(
+ "`[api] access_control_allow_origins` must not contain `*`: the
wildcard origin is "
+ "incompatible with the credentialed CORS Airflow's API requires,
and browsers will "
+ "reject every cross-origin response. List the exact origins that
need access "
+ "(e.g. `https://airflow.mycompany.com`) instead."
+ )
if allow_origins or allow_methods or allow_headers:
app.add_middleware(
CORSMiddleware,
allow_origins=allow_origins,
- allow_credentials=allow_credentials,
+ 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 4c650bbeb64..ae47f132b08 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -1821,21 +1821,14 @@ api:
access_control_allow_origins:
description: |
Indicates whether the response can be shared with requesting code from
the given origins.
- Separate URLs with space.
+ Separate URLs with space. Wildcard (``*``) is not allowed: Airflow's
API requires
+ credentialed CORS, which is incompatible with a wildcard origin per
the CORS spec, and
+ browsers reject any response that combines the two. List exact origins
instead
+ (for example ``https://airflow.mycompany.com``).
type: string
version_added: 2.2.0
example: ~
default: ""
- access_control_allow_credentials:
- description: |
- Whether the FastAPI server includes the
``Access-Control-Allow-Credentials`` header on
- CORS responses. Defaults to True to preserve existing behavior; set to
False if you have
- configured ``access_control_allow_origins`` and do not want browsers
to send credentials
- (cookies, Authorization header) with cross-origin requests.
- type: boolean
- version_added: 3.2.2
- example: ~
- default: "True"
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 7a999e0f3b3..9e3c7219f03 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
@@ -149,20 +149,31 @@ class TestRouterLevelDefaultDeny:
)
-class TestCorsMiddlewareAllowCredentials:
- @pytest.mark.parametrize(
- ("config_value", "expected_allow_credentials"),
- [(None, True), ("True", True), ("False", False)],
- )
- def test_init_config_passes_allow_credentials(self, config_value,
expected_allow_credentials):
- config = {("api", "access_control_allow_origins"):
"https://example.com"}
- if config_value is not None:
- config[("api", "access_control_allow_credentials")] = config_value
-
- with conf_vars(config):
+class TestCorsMiddlewareConfig:
+ def test_init_config_enables_credentialed_cors_for_explicit_origins(self):
+ with conf_vars({("api", "access_control_allow_origins"):
"https://example.com"}):
app = FastAPI()
init_config(app)
cors_middlewares = [m for m in app.user_middleware if m.cls is
CORSMiddleware]
assert len(cors_middlewares) == 1
- assert cors_middlewares[0].kwargs["allow_credentials"] is
expected_allow_credentials
+ assert cors_middlewares[0].kwargs["allow_credentials"] is True
+ assert cors_middlewares[0].kwargs["allow_origins"] ==
["https://example.com"]
+
+ @pytest.mark.parametrize(
+ "origins",
+ ["*", "https://example.com,*", "*,https://example.com"],
+ )
+ def test_init_config_rejects_wildcard_origin(self, origins):
+ """Wildcard origin is incompatible with credentialed CORS; reject it
at startup.
+
+ Browsers refuse any response that combines
``Access-Control-Allow-Origin: *`` with
+ ``Access-Control-Allow-Credentials: true``, so silently accepting
``*`` would just ship
+ a configuration where every cross-origin request fails. Fail loudly
instead.
+ """
+ from airflow.exceptions import AirflowConfigException
+
+ with conf_vars({("api", "access_control_allow_origins"): origins}):
+ app = FastAPI()
+ with pytest.raises(AirflowConfigException, match=r"must not
contain `\*`"):
+ init_config(app)