Repository: apache/airflow

Issue:
CORS access_control_allow_origin header never returned
### Apache Airflow version

2.2.2 (latest released)

### What happened

To fix CORS problem added the [access_control_allow_headers](https://airflow.apache.org/docs/apache-airflow/stable/configurations-ref.html#access-control-allow-headers), [access_control_allow_methods](https://airflow.apache.org/docs/apache-airflow/stable/configurations-ref.html#access-control-allow-methods), [access_control_allow_origins](https://airflow.apache.org/docs/apache-airflow/stable/configurations-ref.html#access-control-allow-origins) variables to the 2.2.2 docker-compose file provided in documentation. Both header, and methods returns with the correct value, but origins never does.

### What you expected to happen

The CORS response returning with provided origin header value.

### How to reproduce

Download the latest docker-compose from documentation add the following lines:
`AIRFLOW__API__ACCESS_CONTROL_ALLOW_HEADERS: 'content-type, origin, authorization, accept'`
`AIRFLOW__API__ACCESS_CONTROL_ALLOW_METHODS: 'GET, POST, OPTIONS, DELETE'`
`AIRFLOW__API__ACCESS_CONTROL_ALLOW_ORIGINS: '*'`

run and call with a CORS preflight

### Operating System

Windows 11

### Versions of Apache Airflow Providers

_No response_

### Deployment

Docker-Compose

### Deployment details

_No response_

### Anything else

It's repeatable regardless of ORIGINS value. There was a name change on this variable that's possibly not handled.
On 2.1.4 the same works without problems.

### Are you willing to submit PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:
Thanks for opening your first issue here! Be sure to follow the issue template!

@ryanahamilton I know you were using and depending on CORS -- any ideas here?
Looks like the logic does not handle wildcard expansion:

https://github.com/apache/airflow/blob/b0eecc3d997ebbdb1fe6b6dfe7134238543f3dfa/airflow/www/extensions/init_views.py#L167-L171
Was anybody able to fix a fix to the CORS issue for airflow API? 
We are facing a similar issue and wondered if its was a v2.2..2 specific. 
Feel free to fix it, pull requests are open to anyone 🙂 

Resolving patch:
diff --git a/airflow/www/extensions/init_views.py b/airflow/www/extensions/init_views.py
--- a/airflow/www/extensions/init_views.py
+++ b/airflow/www/extensions/init_views.py
@@ -159,11 +159,13 @@ def set_cors_headers_on_response(response):
     allow_headers = conf.get('api', 'access_control_allow_headers')
     allow_methods = conf.get('api', 'access_control_allow_methods')
     allow_origins = conf.get('api', 'access_control_allow_origins')
-    if allow_headers is not None:
+    if allow_headers:
         response.headers['Access-Control-Allow-Headers'] = allow_headers
-    if allow_methods is not None:
+    if allow_methods:
         response.headers['Access-Control-Allow-Methods'] = allow_methods
-    if allow_origins is not None:
+    if allow_origins == '*':
+        response.headers['Access-Control-Allow-Origin'] = '*'
+    elif allow_origins:
         allowed_origins = allow_origins.split(' ')
         origin = request.environ.get('HTTP_ORIGIN', allowed_origins[0])
         if origin in allowed_origins:


Test patch:
diff --git a/tests/api_connexion/test_cors.py b/tests/api_connexion/test_cors.py
new file mode 100644
--- /dev/null
+++ b/tests/api_connexion/test_cors.py
@@ -0,0 +1,140 @@
+# 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 base64 import b64encode
+
+import pytest
+
+from tests.test_utils.config import conf_vars
+from tests.test_utils.db import clear_db_pools
+
+
+class BaseTestAuth:
+    @pytest.fixture(autouse=True)
+    def set_attrs(self, minimal_app_for_api):
+        self.app = minimal_app_for_api
+
+        sm = self.app.appbuilder.sm
+        tester = sm.find_user(username="test")
+        if not tester:
+            role_admin = sm.find_role("Admin")
+            sm.add_user(
+                username="test",
+                first_name="test",
+                last_name="test",
+                email="test@fab.org",
+                role=role_admin,
+                password="test",
+            )
+
+
+class TestEmptyCors(BaseTestAuth):
+    @pytest.fixture(autouse=True, scope="class")
+    def with_basic_auth_backend(self, minimal_app_for_api):
+        from airflow.www.extensions.init_security import init_api_experimental_auth
+
+        old_auth = getattr(minimal_app_for_api, 'api_auth')
+
+        try:
+            with conf_vars({("api", "auth_backends"): "airflow.api.auth.backend.basic_auth"}):
+                init_api_experimental_auth(minimal_app_for_api)
+                yield
+        finally:
+            setattr(minimal_app_for_api, 'api_auth', old_auth)
+
+    def test_empty_cors_headers(self):
+        token = "Basic " + b64encode(b"test:test").decode()
+        clear_db_pools()
+
+        with self.app.test_client() as test_client:
+            response = test_client.get("/api/v1/pools", headers={"Authorization": token})
+            assert response.status_code == 200
+            assert 'Access-Control-Allow-Headers' not in response.headers
+            assert 'Access-Control-Allow-Methods' not in response.headers
+            assert 'Access-Control-Allow-Origin' not in response.headers
+
+
+class TestCorsOrigin(BaseTestAuth):
+    @pytest.fixture(autouse=True, scope="class")
+    def with_basic_auth_backend(self, minimal_app_for_api):
+        from airflow.www.extensions.init_security import init_api_experimental_auth
+
+        old_auth = getattr(minimal_app_for_api, 'api_auth')
+
+        try:
+            with conf_vars(
+                {
+                    ("api", "auth_backends"): "airflow.api.auth.backend.basic_auth",
+                    ("api", "access_control_allow_origins"): "http://apache.org http://example.com",
+                }
+            ):
+                init_api_experimental_auth(minimal_app_for_api)
+                yield
+        finally:
+            setattr(minimal_app_for_api, 'api_auth', old_auth)
+
+    def test_cors_origin_reflection(self):
+        token = "Basic " + b64encode(b"test:test").decode()
+        clear_db_pools()
+
+        with self.app.test_client() as test_client:
+            response = test_client.get("/api/v1/pools", headers={"Authorization": token})
+            assert response.status_code == 200
+            assert response.headers['Access-Control-Allow-Origin'] == 'http://apache.org'
+
+            response = test_client.get(
+                "/api/v1/pools", headers={"Authorization": token, "Origin": "http://apache.org"}
+            )
+            assert response.status_code == 200
+            assert response.headers['Access-Control-Allow-Origin'] == 'http://apache.org'
+
+            response = test_client.get(
+                "/api/v1/pools", headers={"Authorization": token, "Origin": "http://example.com"}
+            )
+            assert response.status_code == 200
+            assert response.headers['Access-Control-Allow-Origin'] == 'http://example.com'
+
+
+class TestCorsWildcard(BaseTestAuth):
+    @pytest.fixture(autouse=True, scope="class")
+    def with_basic_auth_backend(self, minimal_app_for_api):
+        from airflow.www.extensions.init_security import init_api_experimental_auth
+
+        old_auth = getattr(minimal_app_for_api, 'api_auth')
+
+        try:
+            with conf_vars(
+                {
+                    ("api", "auth_backends"): "airflow.api.auth.backend.basic_auth",
+                    ("api", "access_control_allow_origins"): "*",
+                }
+            ):
+                init_api_experimental_auth(minimal_app_for_api)
+                yield
+        finally:
+            setattr(minimal_app_for_api, 'api_auth', old_auth)
+
+    def test_cors_origin_reflection(self):
+        token = "Basic " + b64encode(b"test:test").decode()
+        clear_db_pools()
+
+        with self.app.test_client() as test_client:
+            response = test_client.get(
+                "/api/v1/pools", headers={"Authorization": token, "Origin": "http://example.com"}
+            )
+            assert response.status_code == 200
+            assert response.headers['Access-Control-Allow-Origin'] == '*'