Repository: falconry/falcon
Pull request #1523 (2019-05-06 18:20:18)

feat(API): Adding the flag to handle basic CORS policies in the framework
Added a new flag to `falcon.API` class init method in order to turn on/off feature to handle basic CORS policies in the framework. By default, it will come off so all request must meet CORS policies.

Addresses: #1194 

codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1523?src=pr&el=h1) Report
> Merging [#1523](https://codecov.io/gh/falconry/falcon/pull/1523?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/ac0e33b91e73cc569ca6d991c8e55408829f2375?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1523/graphs/tree.svg?width=650&token=zOtkfNQ3h6&height=150&src=pr)](https://codecov.io/gh/falconry/falcon/pull/1523?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1523   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          38      38           
  Lines        2561    2561           
  Branches      391     391           
======================================
  Hits         2561    2561
```



------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1523?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1523?src=pr&el=footer). Last update [ac0e33b...e444228](https://codecov.io/gh/falconry/falcon/pull/1523?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

kgriffs: I went ahead and made a few modifications to clarify the docs and ensure full test coverage.
kgriffs: I'll add the news fragment in a followup PR after this is merged.

Patch:
diff --git a/docs/api/cors.rst b/docs/api/cors.rst
new file mode 100644
index 0000000..e67af16
--- /dev/null
+++ b/docs/api/cors.rst
@@ -0,0 +1,34 @@
+.. _cors:
+
+CORS
+=====
+
+`Cross Origin Resource Sharing <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>`_
+(CORS) is an additional security check performed by modern
+browsers to prevent unauthorized requests between different domains.
+
+When implementing
+a web API, it is common to have to also implement a CORS policy. Therefore,
+Falcon provides an easy way to enable a simple CORS policy via a flag passed
+to :any:`falcon.API`.
+
+By default, Falcon's built-in CORS support is disabled, so that any cross-origin
+requests will be blocked by the browser. Passing ``cors_enabled=True`` will
+cause the framework to include the necessary response headers to allow access
+from any origin to any route in the app. Individual responders may override this
+behavior by setting the Access-Control-Allow-Origin header explicitly.
+
+Whether or not you implement a CORS policy, we recommend also putting a robust
+AuthN/Z layer in place to authorize individual clients, as needed, to protect
+sensitive resources.
+
+Usage
+-----
+
+.. code:: python
+
+    import falcon
+
+    # Enable a simple CORS policy for all routes
+    app = falcon.API(cors_enable=True)
+
diff --git a/docs/api/index.rst b/docs/api/index.rst
index 3e6e72d..52622f8 100644
--- a/docs/api/index.rst
+++ b/docs/api/index.rst
@@ -12,6 +12,7 @@ Classes and Functions
    media
    redirects
    middleware
+   cors
    hooks
    routing
    util
diff --git a/falcon/api.py b/falcon/api.py
index b6146a2..43f9a7e 100644
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -20,6 +20,7 @@ import re
 from falcon import api_helpers as helpers, DEFAULT_MEDIA_TYPE, routing
 from falcon.http_error import HTTPError
 from falcon.http_status import HTTPStatus
+from falcon.middlewares import CORSMiddleware
 from falcon.request import Request, RequestOptions
 import falcon.responders
 from falcon.response import Response, ResponseOptions
@@ -136,6 +137,11 @@ class API:
             when that same component's ``process_request()`` (or that of
             a component higher up in the stack) raises an exception.
 
+        cors_enable (bool): Set this flag to ``True`` to enable a simple
+            CORS policy for all routes, including support for preflighted
+            requests (default ``False``).
+            (See also: :ref:`CORS <cors>`)
+
     Attributes:
         req_options: A set of behavioral options related to incoming
             requests. (See also: :py:class:`~.RequestOptions`)
@@ -155,16 +161,34 @@ class API:
                  '_error_handlers', '_media_type', '_router', '_sinks',
                  '_serialize_error', 'req_options', 'resp_options',
                  '_middleware', '_independent_middleware', '_router_search',
-                 '_static_routes')
+                 '_static_routes', '_cors_enable')
 
     def __init__(self, media_type=DEFAULT_MEDIA_TYPE,
                  request_type=Request, response_type=Response,
                  middleware=None, router=None,
-                 independent_middleware=True):
+                 independent_middleware=True, cors_enable=False):
         self._sinks = []
         self._media_type = media_type
         self._static_routes = []
 
+        if cors_enable:
+            cm = CORSMiddleware()
+
+            if middleware is None:
+                middleware = [cm]
+            else:
+                try:
+                    # NOTE(kgriffs): Check to see if middleware is an
+                    #   iterable, and if so, append the CORSMiddleware
+                    #   instance.
+                    iter(middleware)
+                    middleware = list(middleware)
+                    middleware.append(cm)
+                except TypeError:
+                    # NOTE(kgriffs): Assume the middleware kwarg references
+                    #   a single middleware component.
+                    middleware = [middleware, cm]
+
         # set middleware
         self._middleware = helpers.prepare_middleware(
             middleware, independent_middleware=independent_middleware)
@@ -204,7 +228,6 @@ class API:
                 status and headers on a response.
 
         """
-
         req = self._request_type(env, options=self.req_options)
         resp = self._response_type(options=self.resp_options)
         resource = None
diff --git a/falcon/middlewares.py b/falcon/middlewares.py
new file mode 100644
index 0000000..97fb8df
--- /dev/null
+++ b/falcon/middlewares.py
@@ -0,0 +1,29 @@
+
+class CORSMiddleware(object):
+    def process_response(self, req, resp, resource, req_succeeded):
+        """Implement a simple blanket CORS policy for all routes.
+
+        This middleware provides a simple out-of-the box CORS policy,
+        including handling of preflighted requests from the browser.
+
+        See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
+        """
+
+        if resp.get_header('Access-Control-Allow-Origin') is None:
+            resp.set_header('Access-Control-Allow-Origin', '*')
+
+        if (req_succeeded and
+                req.method == 'OPTIONS' and
+                req.get_header('Access-Control-Request-Method')):
+
+            # NOTE(kgriffs): This is a CORS preflight request. Patch the
+            #   response accordingly.
+
+            allow = resp.get_header('Allow')
+            resp.delete_header('Allow')
+
+            allow_headers = req.get_header('Access-Control-Request-Headers', default='*')
+
+            resp.set_header('Access-Control-Allow-Methods', allow)
+            resp.set_header('Access-Control-Allow-Headers', allow_headers)
+            resp.set_header('Access-Control-Max-Age', '86400')  # 24 hours


Test patch:
diff --git a/tests/test_headers.py b/tests/test_headers.py
index 1ee19c8..1c29514 100644
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -16,6 +16,12 @@ def client():
     return testing.TestClient(app)
 
 
+@pytest.fixture(scope='function')
+def cors_client():
+    app = falcon.API(cors_enable=True)
+    return testing.TestClient(app)
+
+
 class XmlResource:
     def __init__(self, content_type):
         self.content_type = content_type
@@ -233,6 +239,16 @@ class ExpiresHeaderResource:
         resp.expires = self._expires
 
 
+class CORSHeaderResource:
+
+    def on_get(self, req, resp):
+        resp.body = "I'm a CORS test response"
+
+    def on_delete(self, req, resp):
+        resp.set_header('Access-Control-Allow-Origin', 'example.com')
+        resp.body = "I'm a CORS test response"
+
+
 class TestHeaders:
 
     def test_content_length(self, client):
@@ -691,6 +707,46 @@ class TestHeaders:
         content_length = '0'
         assert result.headers['Content-Length'] == content_length
 
+    def test_disabled_cors_should_not_add_any_extra_headers(self, client):
+        client.app.add_route('/', CORSHeaderResource())
+        result = client.simulate_get()
+        assert 'Access-Control-Allow-Origin'.lower() not in dict(
+            result.headers.lower_items()).keys()
+
+    def test_enabled_cors_should_add_extra_headers_on_response(self, cors_client):
+        cors_client.app.add_route('/', CORSHeaderResource())
+        result = cors_client.simulate_get()
+        assert 'Access-Control-Allow-Origin'.lower() in dict(
+            result.headers.lower_items()).keys()
+
+    def test_enabled_cors_should_accept_all_origins_requests(self, cors_client):
+        cors_client.app.add_route('/', CORSHeaderResource())
+
+        result = cors_client.simulate_get()
+        assert result.headers['Access-Control-Allow-Origin'] == '*'
+
+        result = cors_client.simulate_delete()
+        assert result.headers['Access-Control-Allow-Origin'] == 'example.com'
+
+    def test_enabled_cors_handles_preflighting(self, cors_client):
+        cors_client.app.add_route('/', CORSHeaderResource())
+        result = cors_client.simulate_options(headers=(
+            ('Access-Control-Request-Method', 'GET'),
+            ('Access-Control-Request-Headers', 'X-PINGOTHER, Content-Type'),
+        ))
+        assert result.headers['Access-Control-Allow-Methods'] == 'DELETE, GET'
+        assert result.headers['Access-Control-Allow-Headers'] == 'X-PINGOTHER, Content-Type'
+        assert result.headers['Access-Control-Max-Age'] == '86400'  # 24 hours in seconds
+
+    def test_enabled_cors_handles_preflighting_no_headers_in_req(self, cors_client):
+        cors_client.app.add_route('/', CORSHeaderResource())
+        result = cors_client.simulate_options(headers=(
+            ('Access-Control-Request-Method', 'POST'),
+        ))
+        assert result.headers['Access-Control-Allow-Methods'] == 'DELETE, GET'
+        assert result.headers['Access-Control-Allow-Headers'] == '*'
+        assert result.headers['Access-Control-Max-Age'] == '86400'  # 24 hours in seconds
+
     # ----------------------------------------------------------------------
     # Helpers
     # ----------------------------------------------------------------------
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 4e2d31f..008cf21 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -137,6 +137,12 @@ class EmptySignatureMiddleware:
         pass
 
 
+class TestCorsResource:
+    def on_get(self, req, resp, **kwargs):
+        resp.status = falcon.HTTP_200
+        resp.body = 'Test'
+
+
 class TestMiddleware:
     def setup_method(self, method):
         # Clear context
@@ -804,3 +810,19 @@ class TestShortCircuiting(TestMiddleware):
 
         # NOTE(kgriffs): Short-circuiting does not affect process_response()
         assert 'end_time' in context
+
+
+class TestCORSMiddlewareWithAnotherMiddleware(TestMiddleware):
+
+    @pytest.mark.parametrize('mw', [
+        CaptureResponseMiddleware(),
+        [CaptureResponseMiddleware()],
+        (CaptureResponseMiddleware(),),
+        iter([CaptureResponseMiddleware()]),
+    ])
+    def test_api_initialization_with_cors_enabled_and_middleware_param(self, mw):
+        app = falcon.API(middleware=mw, cors_enable=True)
+        app.add_route('/', TestCorsResource())
+        client = testing.TestClient(app)
+        result = client.simulate_get()
+        assert result.headers['Access-Control-Allow-Origin'] == '*'