Repository: encode/starlette

Issue:
Credentialed CORS standard requests should not respond with wildcard origins
See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Credentialed_requests_and_wildcards 

If a standard request is made, that includes any cookie headers, then CORSMiddleware *ought* to strictly respond with the requested origin, rather than a wildcard.

This is actually potentially a bit fiddly since we maybe also need to make sure to *set or add* Vary: Origin in those cases, in order to ensure correct cacheability.


Discussion:
Sounds good.

Also, on the subject of the `Vary: Origin` header, it looks like we should be setting that header for specified origins as well.

From https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#The_HTTP_response_headers

> If the server specifies a single origin rather than the "*" wildcard, then the server should also include Origin in the Vary response header — to indicate to clients that server responses will differ based on the value of the Origin request header.
Gotcha, yup. Setting the Vary header on *standard* responses is complicated by the fact that it may *already* be set, so you need to make so to add-or-include "Origin" in the header value. I guess we should probably start by only considering the no credentialed "wildcard" origins part of this.

Here's the current idea I'm playing with..
```python
async def send(self, message, send=None, request_headers=None):
    ...
    ...
    origin = request_headers.get("origin")
    cookie = request_headers.get("cookie")
    vary = request_headers.get("vary")

    if cookie and self.allow_all_origins:
        headers["Vary"] = f"{vary}, Origin" if vary else "Origin"
        self.simple_headers["Access-Control-Allow-Origin"] = origin
    elif not self.allow_all_origins and self.is_allowed_origin(origin=origin):
        headers["Access-Control-Allow-Origin"] = origin
```
Cool. Possibly use `has_cookie = "cookie" in request_headers` just to avoid any Falsey-evaluating edge cases. Also, vary: origin ought to get set in *any* case except "self.allow_all_origins".
Okay, I’ll put a PR in at some point today when I have time.

Resolving patch:
diff --git a/starlette/middleware/cors.py b/starlette/middleware/cors.py
--- a/starlette/middleware/cors.py
+++ b/starlette/middleware/cors.py
@@ -32,6 +32,8 @@ def __init__(
         simple_headers = {}
         if "*" in allow_origins:
             simple_headers["Access-Control-Allow-Origin"] = "*"
+        else:
+            simple_headers["Vary"] = "Origin"
         if allow_credentials:
             simple_headers["Access-Control-Allow-Credentials"] = "true"
         if expose_headers:
@@ -74,7 +76,7 @@ def __call__(self, scope: Scope):
                     return self.preflight_response(request_headers=headers)
                 else:
                     return functools.partial(
-                        self.simple_response, scope=scope, origin=origin
+                        self.simple_response, scope=scope, request_headers=headers
                     )
 
         return self.app(scope)
@@ -130,22 +132,31 @@ def preflight_response(self, request_headers):
 
         return PlainTextResponse("OK", status_code=200, headers=headers)
 
-    async def simple_response(self, receive, send, scope=None, origin=None):
+    async def simple_response(self, receive, send, scope=None, request_headers=None):
         inner = self.app(scope)
-        send = functools.partial(self.send, send=send, origin=origin)
+        send = functools.partial(self.send, send=send, request_headers=request_headers)
         await inner(receive, send)
 
-    async def send(self, message, send=None, origin=None):
+    async def send(self, message, send=None, request_headers=None):
         if message["type"] != "http.response.start":
             await send(message)
             return
 
         message.setdefault("headers", [])
         headers = MutableHeaders(message["headers"])
+        origin = request_headers["Origin"]
+        has_cookie = "cookie" in request_headers
+
+        # If request includes any cookie headers, then we must respond
+        # with the specific origin instead of '*'.
+        if self.allow_all_origins and has_cookie:
+            self.simple_headers["Access-Control-Allow-Origin"] = origin
 
         # If we only allow specific origins, then we have to mirror back
         # the Origin header in the response.
-        if not self.allow_all_origins and self.is_allowed_origin(origin=origin):
+        elif not self.allow_all_origins and self.is_allowed_origin(origin=origin):
             headers["Access-Control-Allow-Origin"] = origin
+            if "vary" in headers:
+                self.simple_headers["Vary"] = f"{headers.get('vary')}, Origin"
         headers.update(self.simple_headers)
         await send(message)


Test patch:
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -206,3 +206,60 @@ def homepage(request):
     assert response.status_code == 400
     assert response.text == "Disallowed CORS origin"
     assert "access-control-allow-origin" not in response.headers
+
+
+def test_cors_credentialed_requests_return_specific_origin():
+    app = Starlette()
+
+    app.add_middleware(CORSMiddleware, allow_origins=["*"])
+
+    @app.route("/")
+    def homepage(request):
+        return PlainTextResponse("Homepage", status_code=200)
+
+    client = TestClient(app)
+
+    # Test credentialed request
+    headers = {"Origin": "https://example.org", "Cookie": "star_cookie=sugar"}
+    response = client.get("/", headers=headers)
+    assert response.status_code == 200
+    assert response.text == "Homepage"
+    assert response.headers["access-control-allow-origin"] == "https://example.org"
+
+
+def test_cors_vary_header_defaults_to_origin():
+    app = Starlette()
+
+    app.add_middleware(CORSMiddleware, allow_origins=["https://example.org"])
+
+    headers = {"Origin": "https://example.org"}
+
+    @app.route("/")
+    def homepage(request):
+        return PlainTextResponse("Homepage", status_code=200)
+
+    client = TestClient(app)
+
+    response = client.get("/", headers=headers)
+    assert response.status_code == 200
+    assert response.headers["vary"] == "Origin"
+
+
+def test_cors_vary_header_is_properly_set():
+    app = Starlette()
+
+    app.add_middleware(CORSMiddleware, allow_origins=["https://example.org"])
+
+    headers = {"Origin": "https://example.org"}
+
+    @app.route("/")
+    def homepage(request):
+        return PlainTextResponse(
+            "Homepage", status_code=200, headers={"Vary": "Accept-Encoding"}
+        )
+
+    client = TestClient(app)
+
+    response = client.get("/", headers=headers)
+    assert response.status_code == 200
+    assert response.headers["vary"] == "Accept-Encoding, Origin"