Repository: encode/starlette

Issue:
CORS support
Related to https://github.com/encode/starlette/issues/1

I'm having trouble figuring out an appropriate pattern for including CORS support. My use-case is that I am connecting a React app to an ASGI backend that uses Starlette's routing and response classes, but enabling CORS support would require altering the response class headers which cannot be accomplished simply by wrapping the ASGI app at this point.

What should CORS support look like in Starlette?

An example of my use-case can be found [here](https://github.com/erm/apistar-react/blob/master/app.py), it uses APIStar and a CORS extension to achieve what I want to do with Starlette as well. Below is my work-in-progress attempt at an ASGI CORS middleware that I would ultimately like to work into Starlette somehow.


```python3
from starlette import PlainTextResponse
from starlette.datastructures import Headers

ACCESS_CONTROL_ALLOW_ORIGIN = b"Access-Control-Allow-Origin"
ACCESS_CONTROL_EXPOSE_HEADERS = b"Access-Control-Expose-Headers"
ACCESS_CONTROL_ALLOW_CREDENTIALS = b"Access-Control-Allow-Credentials"
ACCESS_CONTROL_ALLOW_HEADERS = b"Access-Control-Allow-Headers"
ACCESS_CONTROL_ALLOW_METHODS = b"Access-Control-Allow-Methods"
ACCESS_CONTROL_MAX_AGE = b"Access-Control-Max-Age"
ACCESS_CONTROL_REQUEST_METHOD = b"Access-Control-Request-Method"
ACCESS_CONTROL_REQUEST_HEADERS = b"Access-Control-Request-Headers"


DEFAULT_OPTIONS = {
    "allow_origin": [b"*"],
    "allow_credentials": False,
    "allow_headers": [b"*"],
    "allow_methods": [
        b"GET",
        b"HEAD",
        b"POST",
        b"OPTIONS",
        b"PUT",
        b"PATCH",
        b"DELETE",
    ],
    "expose_headers": [b""],
    "max_age": b"86400",
}


class CORSMiddleware:
    def __init__(self, app, options=None):
        self.app = app
        self.options = options or DEFAULT_OPTIONS

    def __call__(self, scope):
        headers = Headers(scope.get("headers", []))
        origin = headers.get("origin", None)
        if origin is None:
            return self.app(scope)

        allow_origin = self.options["allow_origin"]
        if origin not in allow_origin and b"*" not in allow_origin:
            return PlainTextResponse("Origin not allowed", status_code=400)

        if scope["method"] == "OPTIONS":
            method = headers.get(ACCESS_CONTROL_REQUEST_METHOD, None)
            if method is None:
                return PlainTextResponse(
                    "Access-Control-Request-Method header missing", status_code=400
                )

        cors_headers = []
        if origin != "*" and len(allow_origin) > 1:  # todo double-check this
            cors_headers.append((b"vary", b"origin"))

        cors_allow_origin = b", ".join(self.options["allow_origin"])
        cors_allow_credentials = self.options["allow_credentials"]
        cors_allow_headers = b", ".join(self.options["allow_headers"])
        cors_allow_methods = b", ".join(self.options["allow_methods"])
        cors_expose_headers = b", ".join(self.options["expose_headers"])
        cors_max_age = self.options["max_age"]

        cors_headers.append((ACCESS_CONTROL_ALLOW_ORIGIN, cors_allow_origin))
        cors_headers.append((ACCESS_CONTROL_ALLOW_METHODS, cors_allow_methods))
        cors_headers.append((ACCESS_CONTROL_ALLOW_HEADERS, cors_allow_headers))
        cors_headers.append((ACCESS_CONTROL_ALLOW_CREDENTIALS, cors_allow_credentials))
        cors_headers.append((ACCESS_CONTROL_EXPOSE_HEADERS, cors_expose_headers))
        cors_headers.append((ACCESS_CONTROL_MAX_AGE, cors_max_age))

        scope["headers"].extend(cors_headers)
        print(scope["headers"])

        return self.app(scope)
```

Comments:

Patch:
diff --git a/docs/background.md b/docs/background.md
index 0b6789a..de7135f 100644
--- a/docs/background.md
+++ b/docs/background.md
@@ -6,7 +6,7 @@ the response has been sent.
 
 ### Background Task
 
-Signature: `BackgroundTask(func, *args, **kwargs)
+Signature: `BackgroundTask(func, *args, **kwargs)`
 
 ```python
 from starlette.applications import Starlette
diff --git a/docs/middleware.md b/docs/middleware.md
new file mode 100644
index 0000000..9f0c026
--- /dev/null
+++ b/docs/middleware.md
@@ -0,0 +1,111 @@
+
+Starlette includes several middleware classes for adding behaviour that is applied across your entire application. These are all implemented as standard ASGI
+middleware classes, and can be applied either to Starlette or to any other ASGI application.
+
+The Starlette application class allows you to include the ASGI middleware
+in a way that ensures that it remains wrapped by the exception handler.
+
+```python
+from starlette.applications import Starlette
+from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
+from starlette.middleware.trustedhost import TrustedHostMiddleware
+
+
+app = Starlette()
+
+# Ensure that all requests include an 'example.com' host header,
+# and strictly enforce https-only access.
+app.add_middleware(TrustedHostMiddleware, allowed_hosts=['example.com'])
+app.add_middleware(HTTPSRedirectMiddleware)
+```
+
+The following middleware implementations are available in the Starlette package:
+
+## CORSMiddleware
+
+Adds appropriate [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) to outgoing responses in order to allow cross-origin requests from browsers.
+
+The default parameters used by the CORSMiddleware implementation are restrictive by default,
+so you'll need to explicitly enable particular origins, methods, or headers, in order
+for browsers to be permitted to use them in a Cross-Domain context.
+
+```python
+from starlette.applications import Starlette
+from starlette.middleware.cors import CORSMiddleware
+
+
+app = Starlette()
+app.add_middleware(CORSMiddleware, allow_origins=['*'])
+```
+
+The following arguments are supported:
+
+* `allow_origins` - A list of origins that should be permitted to make cross-origin requests. eg. `['https://example.org', 'https://www.example.org']`. You can use `['*']` to allow any origin.
+* `allow_methods` - A list of HTTP methods that should be allowed for cross-origin requests. Defaults to `['GET']`. You can use `['*']` to allow all standard methods.
+* `allow_headers` - A list of HTTP request headers that should be supported for cross-origin requests. Defaults to `[]`. You can use `['*']` to allow all headers. The `Accept`, `Accept-Language`, `Content-Language` and `Content-Type` headers are always allowed for CORS requests.
+* `allow_credentials` - Indicate that cookies should be supported for cross-origin requests. Defaults to `False`.
+* `expose_headers` - Indicate any response headers that should be made accessible to the browser. Defaults to `[]`.
+* `max_age` - Sets a maximum time in seconds for browsers to cache CORS responses. Defaults to `60`.
+
+The middleware responds to two particular types of HTTP request...
+
+#### CORS preflight requests
+
+These are any `OPTION` request with `Origin` and `Access-Control-Request-Method` headers.
+In this case the middleware will intercept the incoming request and respond with
+appropriate CORS headers, and either a 200 or 400 response for informational purposes.
+
+#### Simple requests
+
+Any request with an `Origin` header. In this case the middleware will pass the
+request through as normal, but will include appropriate CORS headers on the response.
+
+## HTTPSRedirectMiddleware
+
+Enforces that all incoming requests must either be `https` or `wss`. Any incoming
+requests to `http` or `ws` will be redirected to the secure scheme instead.
+
+```python
+from starlette.applications import Starlette
+from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
+
+
+app = Starlette()
+app.add_middleware(HTTPSRedirectMiddleware)
+```
+
+There are no configuration options for this middleware class.
+
+## TrustedHostMiddleware
+
+Enforces that all incoming requests have a correctly set `Host` header, in order
+to guard against HTTP Host Header attacks.
+
+```python
+from starlette.applications import Starlette
+from starlette.middleware.trustedhost import TrustedHostMiddleware
+
+
+app = Starlette()
+app.add_middleware(TrustedHostMiddleware, allowed_hosts=['example.com'])
+```
+
+The following arguments are supported:
+
+* `allowed_hosts` - A list of domain names that should be allowed as hostnames.
+
+If an incoming request does not validate correctly then a 400 response will be sent.
+
+## Using ASGI middleware without Starlette
+
+To wrap ASGI middleware around other ASGI applications, you should use the
+more general pattern of wrapping the application instance:
+
+```python
+app = TrustedHostMiddleware(app, allowed_hosts=['example.com'])
+```
+
+You can do this with a Starlette application instance too, but it is preferable
+to use `.add_middleware`, as it'll ensure that you don't lose the reference
+to the application object, and that the exception handling always wraps around
+any other behaviour.
diff --git a/starlette/datastructures.py b/starlette/datastructures.py
index c5fece3..d00f3f4 100644
--- a/starlette/datastructures.py
+++ b/starlette/datastructures.py
@@ -274,3 +274,7 @@ class MutableHeaders(Headers):
                 return item_value.decode("latin-1")
         self._list.append((set_key, set_value))
         return value
+
+    def update(self, other: dict):
+        for key, val in other.items():
+            self[key] = val
diff --git a/starlette/middleware/cors.py b/starlette/middleware/cors.py
index e69de29..aa367f9 100644
--- a/starlette/middleware/cors.py
+++ b/starlette/middleware/cors.py
@@ -0,0 +1,130 @@
+from starlette.datastructures import Headers, MutableHeaders, URL
+from starlette.responses import PlainTextResponse
+from starlette.types import ASGIApp, ASGIInstance, Scope
+import functools
+import typing
+
+
+ALL_METHODS = ("DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT")
+
+
+class CORSMiddleware:
+    def __init__(
+        self,
+        app: ASGIApp,
+        allow_origins: typing.Sequence[str] = (),
+        allow_methods: typing.Sequence[str] = ("GET",),
+        allow_headers: typing.Sequence[str] = (),
+        allow_credentials: bool = False,
+        expose_headers: typing.Sequence[str] = (),
+        max_age: int = 600,
+    ):
+
+        if "*" in allow_methods:
+            allow_methods = ALL_METHODS
+
+        simple_headers = {}
+        if "*" in allow_origins:
+            simple_headers["Access-Control-Allow-Origin"] = "*"
+        if allow_credentials:
+            simple_headers["Access-Control-Allow-Credentials"] = "true"
+        if expose_headers:
+            simple_headers["Access-Control-Expose-Headers"] = ", ".join(expose_headers)
+
+        preflight_headers = {}
+        if "*" in allow_origins:
+            preflight_headers["Access-Control-Allow-Origin"] = "*"
+        else:
+            preflight_headers["Vary"] = "Origin"
+        preflight_headers.update(
+            {
+                "Access-Control-Allow-Methods": ", ".join(allow_methods),
+                "Access-Control-Max-Age": str(max_age),
+            }
+        )
+        if allow_headers and "*" not in allow_headers:
+            preflight_headers["Access-Control-Allow-Headers"] = ", ".join(allow_headers)
+        if allow_credentials:
+            preflight_headers["Access-Control-Allow-Credentials"] = "true"
+
+        self.app = app
+        self.allow_origins = allow_origins
+        self.allow_methods = allow_methods
+        self.allow_headers = allow_headers
+        self.allow_all_origins = "*" in allow_origins
+        self.allow_all_headers = "*" in allow_headers
+        self.simple_headers = simple_headers
+        self.preflight_headers = preflight_headers
+
+    def __call__(self, scope: Scope):
+        if scope["type"] == "http":
+            method = scope["method"]
+            headers = Headers(scope["headers"])
+            origin = headers.get("origin")
+
+            if origin is not None:
+                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
+                    )
+
+        return self.app(scope)
+
+    def preflight_response(self, request_headers):
+        requested_origin = request_headers["origin"]
+        requested_method = request_headers["access-control-request-method"]
+        requested_headers = request_headers.get("access-control-request-headers")
+        requested_cookie = "cookie" in request_headers
+
+        headers = dict(self.preflight_headers)
+        failures = []
+
+        # 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:
+                headers["Access-Control-Allow-Origin"] = requested_origin
+            else:
+                failures.append("origin")
+
+        if requested_method not in self.allow_methods:
+            failures.append("method")
+
+        # If we allow all headers, then we have to mirror back any requested
+        # headers in the response.
+        if self.allow_all_headers and requested_headers is not None:
+            headers["Access-Control-Allow-Headers"] = requested_headers
+        elif requested_headers is not None:
+            for header in requested_headers.split(","):
+                if header.strip() not in self.allow_headers:
+                    failures.append("headers")
+
+        # We don't strictly need to use 400 responses here, since its up to
+        # the browser to enforce the CORS policy, but its more informative
+        # if we do.
+        if failures:
+            failure_text = "Disallowed CORS " + ", ".join(failures)
+            return PlainTextResponse(failure_text, status_code=400, headers=headers)
+
+        return PlainTextResponse("OK", status_code=200, headers=headers)
+
+    async def simple_response(self, receive, send, scope=None, origin=None):
+        inner = self.app(scope)
+        send = functools.partial(self.send, send=send, origin=origin)
+        await inner(receive, send)
+
+    async def send(self, message, send=None, origin=None):
+        if message["type"] != "http.response.start":
+            await send(message)
+
+        message.setdefault("headers", [])
+        headers = MutableHeaders(message["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 and origin in self.allow_origins:
+            headers["Access-Control-Allow-Origin"] = origin
+        headers.update(self.simple_headers)
+        await send(message)
diff --git a/starlette/middleware/httpsredirect.py b/starlette/middleware/httpsredirect.py
index a608301..6420804 100644
--- a/starlette/middleware/httpsredirect.py
+++ b/starlette/middleware/httpsredirect.py
@@ -1,12 +1,13 @@
 from starlette.datastructures import URL
 from starlette.responses import RedirectResponse
+from starlette.types import ASGIApp, ASGIInstance, Scope
 
 
 class HTTPSRedirectMiddleware:
-    def __init__(self, app):
+    def __init__(self, app: ASGIApp) -> None:
         self.app = app
 
-    def __call__(self, scope):
+    def __call__(self, scope: Scope) -> ASGIInstance:
         if scope["type"] in ("http", "websocket") and scope["scheme"] in ("http", "ws"):
             redirect_scheme = {"http": "https", "ws": "wss"}[scope["scheme"]]
             url = URL(scope=scope)
diff --git a/starlette/middleware/trustedhost.py b/starlette/middleware/trustedhost.py
index a3966e3..d0e57c0 100644
--- a/starlette/middleware/trustedhost.py
+++ b/starlette/middleware/trustedhost.py
@@ -1,14 +1,18 @@
 from starlette.datastructures import Headers
 from starlette.responses import PlainTextResponse
+from starlette.types import ASGIApp, ASGIInstance, Scope
+import typing
 
 
 class TrustedHostMiddleware:
-    def __init__(self, app, allowed_hosts=["*"]):
+    def __init__(
+        self, app: ASGIApp, allowed_hosts: typing.Sequence[str] = ["*"]
+    ) -> None:
         self.app = app
         self.allowed_hosts = allowed_hosts
         self.allow_any = "*" in allowed_hosts
 
-    def __call__(self, scope):
+    def __call__(self, scope: Scope) -> ASGIInstance:
         if scope["type"] in ("http", "websocket") and not self.allow_any:
             headers = Headers(scope["headers"])
             host = headers.get("host")


Test patch:
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 2649134..07a22b0 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -1,6 +1,7 @@
 from starlette.applications import Starlette
-from starlette.middleware.trustedhost import TrustedHostMiddleware
+from starlette.middleware.cors import CORSMiddleware
 from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
+from starlette.middleware.trustedhost import TrustedHostMiddleware
 from starlette.responses import PlainTextResponse
 from starlette.testclient import TestClient
 
@@ -40,3 +41,115 @@ def test_https_redirect_middleware():
     response = client.get("/", allow_redirects=False)
     assert response.status_code == 301
     assert response.headers["location"] == "https://testserver/"
+
+
+def test_cors_allow_all():
+    app = Starlette()
+
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=["*"],
+        allow_headers=["*"],
+        allow_methods=["*"],
+        expose_headers=["X-Status"],
+        allow_credentials=True,
+    )
+
+    @app.route("/")
+    def homepage(request):
+        return PlainTextResponse("Homepage", status_code=200)
+
+    client = TestClient(app)
+
+    # Test pre-flight response
+    headers = {
+        "Origin": "https://example.org",
+        "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"] == "*"
+    assert response.headers["access-control-allow-headers"] == "X-Example"
+
+    # 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"] == "*"
+    assert response.headers["access-control-expose-headers"] == "X-Status"
+
+    # Test non-CORS response
+    response = client.get("/")
+    assert response.status_code == 200
+    assert response.text == "Homepage"
+    assert "access-control-allow-origin" not in response.headers
+
+
+def test_cors_allow_specific_origin():
+    app = Starlette()
+
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=["https://example.org"],
+        allow_headers=["X-Example"],
+    )
+
+    @app.route("/")
+    def homepage(request):
+        return PlainTextResponse("Homepage", status_code=200)
+
+    client = TestClient(app)
+
+    # Test pre-flight response
+    headers = {
+        "Origin": "https://example.org",
+        "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://example.org"
+    assert response.headers["access-control-allow-headers"] == "X-Example"
+
+    # 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 non-CORS response
+    response = client.get("/")
+    assert response.status_code == 200
+    assert response.text == "Homepage"
+    assert "access-control-allow-origin" not in response.headers
+
+
+def test_cors_disallowed_preflight():
+    app = Starlette()
+
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=["https://example.org"],
+        allow_headers=["X-Example"],
+    )
+
+    @app.route("/")
+    def homepage(request):
+        pass  # pragma: no cover
+
+    client = TestClient(app)
+
+    # Test pre-flight response
+    headers = {
+        "Origin": "https://another.org",
+        "Access-Control-Request-Method": "POST",
+        "Access-Control-Request-Headers": "X-Nope",
+    }
+    response = client.options("/", headers=headers)
+    assert response.status_code == 400
+    assert response.text == "Disallowed CORS origin, method, headers"