Repository: encode/starlette

Issue:
Add `allow_origin_regex` to CORSMiddleware.
It'd be helpful if `CORSMiddleware` supported an `allow_origin_regex`, so that users could do...

```python
# Enforce a subdomain CORS policy
app.add_middleware(CORSMiddleware, allow_origin_regex="(http|https)://*.example.com")
```

Or...

```python
# Enforce an HTTPS-only CORS policy.
app.add_middleware(CORSMiddleware, allow_origin_regex="https://*")
```

The string should be compiled to a regex by the middleware and matches should be anchored to the start/end of the origin string.


Discussion:
Could be a good one for contributions.
Perhaps we could add a `matches` method?
```python
def matches(self, scope, origin):
    self.allow_origins = []
    regex = re.compile(self.allow_origin_regex)
    match = regex.match(origin)
    
    if match:
        self.allow_origins.append(origin)
        self.simple_headers["Access-Control-Allow-Origin"] = origin
        return functools.partial(self.simple_response, scope=scope, origin=origin)
    return PlainTextResponse(
        "Disallowed CORS origin", status_code=400,
    )
```
Then we can call it before preflight_response after the origin check
```python
if origin is not None:
    if self.allow_origin_regex is not None:
        return self.matches(scope=scope, origin=origin)
```
I guess this line `if requested_origin in self.allow_origins:` should be:

`if self.is_allowed_origin(origin)`

Then we can have a `is_allowed_origin(origin: str) -> bool` method which defaults to `origin in self.allowed_origins or self.origin_regex.match(origin)` or something like that.
Ah, I see. That's a much better idea. I'll try it out and submit a PR soon.

Resolving patch:
diff --git a/starlette/middleware/cors.py b/starlette/middleware/cors.py
--- a/starlette/middleware/cors.py
+++ b/starlette/middleware/cors.py
@@ -3,6 +3,7 @@
 from starlette.types import ASGIApp, ASGIInstance, Scope
 import functools
 import typing
+import re
 
 
 ALL_METHODS = ("DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT")
@@ -16,6 +17,7 @@ def __init__(
         allow_methods: typing.Sequence[str] = ("GET",),
         allow_headers: typing.Sequence[str] = (),
         allow_credentials: bool = False,
+        allow_origin_regex: str = None,
         expose_headers: typing.Sequence[str] = (),
         max_age: int = 600,
     ):
@@ -23,6 +25,10 @@ def __init__(
         if "*" in allow_methods:
             allow_methods = ALL_METHODS
 
+        if allow_origin_regex is not None:
+            regex = re.compile(allow_origin_regex)
+            allow_origin_regex = regex
+
         simple_headers = {}
         if "*" in allow_origins:
             simple_headers["Access-Control-Allow-Origin"] = "*"
@@ -53,6 +59,7 @@ def __init__(
         self.allow_headers = allow_headers
         self.allow_all_origins = "*" in allow_origins
         self.allow_all_headers = "*" in allow_headers
+        self.allow_origin_regex = allow_origin_regex
         self.simple_headers = simple_headers
         self.preflight_headers = preflight_headers
 
@@ -66,12 +73,22 @@ def __call__(self, scope: Scope):
                 if method == "OPTIONS" and "access-control-request-method" in headers:
                     return self.preflight_response(request_headers=headers)
                 else:
-                    return functools.partial(
-                        self.simple_response, scope=scope, origin=origin
-                    )
+                    if self.is_allowed_origin(origin=origin):
+                        return functools.partial(
+                            self.simple_response, scope=scope, origin=origin
+                        )
+                    return PlainTextResponse("Disallowed CORS origin", status_code=400)
 
         return self.app(scope)
 
+    def is_allowed_origin(self, origin):
+        if self.allow_origin_regex:
+            return self.allow_origin_regex.match(origin)
+        if self.allow_all_origins:
+            return True
+
+        return origin in self.allow_origins
+
     def preflight_response(self, request_headers):
         requested_origin = request_headers["origin"]
         requested_method = request_headers["access-control-request-method"]
@@ -84,7 +101,7 @@ def preflight_response(self, request_headers):
         # If we only allow specific origins, then we have to mirror back
         # the Origin header in the response.
         if not self.allow_all_origins:
-            if requested_origin in self.allow_origins:
+            if self.is_allowed_origin(origin=requested_origin):
                 headers["Access-Control-Allow-Origin"] = requested_origin
             else:
                 failures.append("origin")
@@ -125,7 +142,7 @@ async def send(self, message, send=None, origin=None):
 
         # 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 origin in self.allow_origins:
+        if not self.allow_all_origins and self.is_allowed_origin(origin=origin):
             headers["Access-Control-Allow-Origin"] = 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
@@ -153,3 +153,42 @@ def homepage(request):
     response = client.options("/", headers=headers)
     assert response.status_code == 400
     assert response.text == "Disallowed CORS origin, method, headers"
+
+
+def test_cors_allow_origin_regex():
+    app = Starlette()
+
+    app.add_middleware(
+        CORSMiddleware, allow_headers=["X-Example"], allow_origin_regex="https://*"
+    )
+
+    @app.route("/")
+    def homepage(request):
+        return PlainTextResponse("Homepage", status_code=200)
+
+    client = TestClient(app)
+
+    # Test standard response
+    headers = {"Origin": "https://example.org"}
+    response = client.get("/", headers=headers)
+    assert response.status_code == 200
+    assert response.text == "Homepage"
+    assert response.headers["access-control-allow-origin"] == "https://example.org"
+
+    # Test disallowed origin
+    headers = {"Origin": "http://example.org"}
+    response = client.get("/", headers=headers)
+    assert response.status_code == 400
+    assert response.text == "Disallowed CORS origin"
+
+    # Test pre-flight response
+    headers = {
+        "Origin": "https://another.com",
+        "Access-Control-Request-Method": "GET",
+        "Access-Control-Request-Headers": "X-Example",
+    }
+    response = client.options("/", headers=headers)
+    assert response.status_code == 200
+    assert response.text == "OK"
+    assert response.headers["access-control-allow-origin"] == "https://another.com"
+    assert response.headers["access-control-allow-headers"] == "X-Example"