Repository: litestar-org/litestar

Issue:
Bug: CORS Middleware not setting all headers as per spec
### Description

Right now, there's only a handful of headers that are only being set for the preflight request. They must be set for both the preflight and actual request. 
https://fetch.spec.whatwg.org/#http-responses

Only `Access-Control-Allow-Origin` is being set here.
https://github.com/litestar-org/litestar/blob/1fb981da4b6171cd3fa348c9ffe1c575c5bc862f/litestar/middleware/cors.py#L61-L73

Only `Access-Control-Allow-Credentials` and `Access-Control-Expose-Headers` get set here, and this is what the above code uses to update headers
https://github.com/litestar-org/litestar/blob/1fb981da4b6171cd3fa348c9ffe1c575c5bc862f/litestar/config/cors.py#L123-L136

This still doesn't account for:
- Access-Control-Allow-Methods
- Access-Control-Allow-Headers

which are only set on preflight, but should also be set to the actual request.

### Litestar Version

2.2.1

### Platform

- [X] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/litestar/issues/3178">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/3178/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/3178/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:

Resolving patch:
diff --git a/litestar/middleware/cors.py b/litestar/middleware/cors.py
--- a/litestar/middleware/cors.py
+++ b/litestar/middleware/cors.py
@@ -70,6 +70,15 @@ async def wrapped_send(message: Message) -> None:
                     headers["Access-Control-Allow-Origin"] = origin
                     headers["Vary"] = "Origin"
 
+                # We don't want to overwrite this for preflight requests.
+                allow_headers = headers.get("Access-Control-Allow-Headers")
+                if not allow_headers and self.config.allow_headers:
+                    headers["Access-Control-Allow-Headers"] = ", ".join(sorted(set(self.config.allow_headers)))
+
+                allow_methods = headers.get("Access-Control-Allow-Methods")
+                if not allow_methods and self.config.allow_methods:
+                    headers["Access-Control-Allow-Methods"] = ", ".join(sorted(set(self.config.allow_methods)))
+
             await send(message)
 
         return wrapped_send


Test patch:
diff --git a/tests/unit/test_middleware/test_cors_middleware.py b/tests/unit/test_middleware/test_cors_middleware.py
--- a/tests/unit/test_middleware/test_cors_middleware.py
+++ b/tests/unit/test_middleware/test_cors_middleware.py
@@ -1,4 +1,4 @@
-from typing import Any, Dict, List, Mapping, Optional, cast
+from typing import Any, Dict, List, Literal, Mapping, Optional, Union, cast
 
 import pytest
 
@@ -7,6 +7,7 @@
 from litestar.middleware.cors import CORSMiddleware
 from litestar.status_codes import HTTP_200_OK, HTTP_404_NOT_FOUND
 from litestar.testing import create_test_client
+from litestar.types.asgi_types import Method
 
 
 def test_setting_cors_middleware() -> None:
@@ -38,16 +39,31 @@ def test_setting_cors_middleware() -> None:
 @pytest.mark.parametrize("origin", [None, "http://www.example.com", "https://moishe.zuchmir.com"])
 @pytest.mark.parametrize("allow_origins", ["*", "http://www.example.com", "https://moishe.zuchmir.com"])
 @pytest.mark.parametrize("allow_credentials", [True, False])
-@pytest.mark.parametrize("expose_headers", ["X-First-Header", "SomeOtherHeader", "X-Second-Header"])
+@pytest.mark.parametrize(
+    "expose_headers", [["x-first-header", "x-second-header", "x-third-header"], ["*"], ["x-first-header"]]
+)
+@pytest.mark.parametrize(
+    "allow_headers", [["x-first-header", "x-second-header", "x-third-header"], ["*"], ["x-first-header"]]
+)
+@pytest.mark.parametrize("allow_methods", [["GET", "POST", "PUT", "DELETE"], ["GET", "POST"], ["GET"]])
 def test_cors_simple_response(
-    origin: Optional[str], allow_origins: List[str], allow_credentials: bool, expose_headers: List[str]
+    origin: Optional[str],
+    allow_origins: List[str],
+    allow_credentials: bool,
+    expose_headers: List[str],
+    allow_headers: List[str],
+    allow_methods: List[Union[Literal["*"], "Method"]],
 ) -> None:
     @get("/")
     def handler() -> Dict[str, str]:
         return {"hello": "world"}
 
     cors_config = CORSConfig(
-        allow_origins=allow_origins, allow_credentials=allow_credentials, expose_headers=expose_headers
+        allow_origins=allow_origins,
+        allow_credentials=allow_credentials,
+        expose_headers=expose_headers,
+        allow_headers=allow_headers,
+        allow_methods=allow_methods,
     )
 
     with create_test_client(handler, cors_config=cors_config) as client:
@@ -58,6 +74,8 @@ def handler() -> Dict[str, str]:
         assert cors_config.expose_headers == expose_headers
         assert cors_config.allow_origins == allow_origins
         assert cors_config.allow_credentials == allow_credentials
+        assert cors_config.allow_headers == allow_headers
+        assert cors_config.allow_methods == allow_methods
 
         if origin:
             if cors_config.is_allow_all_origins:
@@ -68,10 +86,20 @@ def handler() -> Dict[str, str]:
                 assert response.headers.get("Access-Control-Expose-Headers") == ", ".join(
                     sorted(set(cors_config.expose_headers))
                 )
+            if cors_config.allow_headers:
+                assert response.headers.get("Access-Control-Allow-Headers") == ", ".join(
+                    sorted(set(cors_config.allow_headers))
+                )
+            if cors_config.allow_methods:
+                assert response.headers.get("Access-Control-Allow-Methods") == ", ".join(
+                    sorted(set(cors_config.allow_methods))
+                )
         else:
             assert "Access-Control-Allow-Origin" not in response.headers
             assert "Access-Control-Allow-Credentials" not in response.headers
             assert "Access-Control-Expose-Headers" not in response.headers
+            assert "Access-Control-Allow-Headers" not in response.headers
+            assert "Access-Control-Allow-Methods" not in response.headers
 
 
 @pytest.mark.parametrize("origin, should_apply_cors", (("http://www.example.com", True), (None, False)))