Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(CORS): improve cors middleware #2326

Merged
merged 17 commits into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/_newsfragments/2325.newandimproved.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
The :class:`~CORSMiddleware` now properly handles the missing ``Allow``
header case, by denying the preflight CORS request.
The static resource has been updated to properly support CORS request,
by allowing GET requests.
9 changes: 9 additions & 0 deletions falcon/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,15 @@ def add_sink(self, sink: SinkCallable, prefix: SinkPrefix = r'/') -> None:
impractical. For example, you might use a sink to create a smart
proxy that forwards requests to one or more backend services.

Note:
To support CORS preflight requests when using the default CORS middleware,
either by setting ``App.cors_enable=True`` or by adding the
:class:`~.CORSMiddleware` to the ``App.middleware``, the sink should
set the ``Allow`` header in the request to the allowed
method values when serving an ``OPTIONS`` request. If the ``Allow`` header
is missing from the response, the default CORS middleware will deny the
preflight request.

Args:
sink (callable): A callable taking the form ``func(req, resp, **kwargs)``.

Expand Down
23 changes: 20 additions & 3 deletions falcon/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@
* https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
* https://www.w3.org/TR/cors/#resource-processing-model

Note:
Falcon will automatically add OPTIONS responders if they are missing from the
responder instances added to the routes. When providing a custom ``on_options``
method, the ``Allow`` headers in the response should be set to the allowed
method values. If the ``Allow`` header is missing from the response,
this middleware will deny the preflight request.

This is also valid when using a sink function.

Keyword Arguments:
allow_origins (Union[str, Iterable[str]]): List of origins to allow (case
sensitive). The string ``'*'`` acts as a wildcard, matching every origin.
Expand Down Expand Up @@ -120,9 +129,17 @@
'Access-Control-Request-Headers', default='*'
)

resp.set_header('Access-Control-Allow-Methods', str(allow))
resp.set_header('Access-Control-Allow-Headers', allow_headers)
resp.set_header('Access-Control-Max-Age', '86400') # 24 hours
if allow is None:
# there is no allow set, remove all access control headers
resp.delete_header('Access-Control-Allow-Methods')
resp.delete_header('Access-Control-Allow-Headers')
resp.delete_header('Access-Control-Max-Age')
resp.delete_header('Access-Control-Expose-Headers')
resp.delete_header('Access-Control-Allow-Origin')

Check warning on line 138 in falcon/middleware.py

View check run for this annotation

Codecov / codecov/patch

falcon/middleware.py#L134-L138

Added lines #L134 - L138 were not covered by tests
else:
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

Check warning on line 142 in falcon/middleware.py

View check run for this annotation

Codecov / codecov/patch

falcon/middleware.py#L140-L142

Added lines #L140 - L142 were not covered by tests

async def process_response_async(self, *args: Any) -> None:
self.process_response(*args)
12 changes: 9 additions & 3 deletions falcon/routing/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@
def __call__(self, req: Request, resp: Response, **kw: Any) -> None:
"""Resource responder for this route."""
assert not kw
if req.method == 'OPTIONS':
# it's likely a CORS request. Set the allow header to the appropriate value.
resp.set_header('Allow', 'GET')
resp.set_header('Content-Length', '0')
return

Check warning on line 190 in falcon/routing/static.py

View check run for this annotation

Codecov / codecov/patch

falcon/routing/static.py#L188-L190

Added lines #L188 - L190 were not covered by tests

without_prefix = req.path[len(self._prefix) :]

# NOTE(kgriffs): Check surrounding whitespace and strip trailing
Expand Down Expand Up @@ -247,9 +253,9 @@

async def __call__(self, req: asgi.Request, resp: asgi.Response, **kw: Any) -> None: # type: ignore[override]
super().__call__(req, resp, **kw)

# NOTE(kgriffs): Fixup resp.stream so that it is non-blocking
resp.stream = _AsyncFileReader(resp.stream) # type: ignore[assignment,arg-type]
if resp.stream is not None: # None when in an option request
# NOTE(kgriffs): Fixup resp.stream so that it is non-blocking
resp.stream = _AsyncFileReader(resp.stream) # type: ignore[assignment,arg-type]

Check warning on line 258 in falcon/routing/static.py

View check run for this annotation

Codecov / codecov/patch

falcon/routing/static.py#L258

Added line #L258 was not covered by tests


class _AsyncFileReader:
Expand Down
58 changes: 51 additions & 7 deletions tests/test_cors_middleware.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pathlib import Path

import pytest

import falcon
Expand Down Expand Up @@ -86,8 +88,7 @@ def test_enabled_cors_handles_preflighting(self, cors_client):
result.headers['Access-Control-Max-Age'] == '86400'
) # 24 hours in seconds

@pytest.mark.xfail(reason='will be fixed in 2325')
def test_enabled_cors_handles_preflighting_custom_option(self, cors_client):
def test_enabled_cors_handles_preflight_custom_option(self, cors_client):
cors_client.app.add_route('/', CORSOptionsResource())
result = cors_client.simulate_options(
headers=(
Expand All @@ -97,11 +98,10 @@ def test_enabled_cors_handles_preflighting_custom_option(self, cors_client):
)
)
assert 'Access-Control-Allow-Methods' not in result.headers
assert (
result.headers['Access-Control-Allow-Headers']
== 'X-PINGOTHER, Content-Type'
)
assert result.headers['Access-Control-Max-Age'] == '86400'
assert 'Access-Control-Allow-Headers' not in result.headers
assert 'Access-Control-Max-Age' not in result.headers
assert 'Access-Control-Expose-Headers' not in result.headers
assert 'Access-Control-Allow-Origin' not in result.headers

def test_enabled_cors_handles_preflighting_no_headers_in_req(self, cors_client):
cors_client.app.add_route('/', CORSHeaderResource())
Expand All @@ -117,6 +117,50 @@ def test_enabled_cors_handles_preflighting_no_headers_in_req(self, cors_client):
result.headers['Access-Control-Max-Age'] == '86400'
) # 24 hours in seconds

def test_enabled_cors_static_route(self, cors_client):
cors_client.app.add_static_route('/static', Path(__file__).parent)
result = cors_client.simulate_options(
f'/static/{Path(__file__).name}',
headers=(
('Origin', 'localhost'),
('Access-Control-Request-Method', 'GET'),
),
)

assert result.headers['Access-Control-Allow-Methods'] == 'GET'
assert result.headers['Access-Control-Allow-Headers'] == '*'
assert result.headers['Access-Control-Max-Age'] == '86400'
assert result.headers['Access-Control-Allow-Origin'] == '*'

@pytest.mark.parametrize('support_options', [True, False])
def test_enabled_cors_sink_route(self, cors_client, support_options):
def my_sink(req, resp):
if req.method == 'OPTIONS' and support_options:
resp.set_header('ALLOW', 'GET')
else:
resp.text = 'my sink'

cors_client.app.add_sink(my_sink, '/sink')
result = cors_client.simulate_options(
'/sink/123',
headers=(
('Origin', 'localhost'),
('Access-Control-Request-Method', 'GET'),
),
)

if support_options:
assert result.headers['Access-Control-Allow-Methods'] == 'GET'
assert result.headers['Access-Control-Allow-Headers'] == '*'
assert result.headers['Access-Control-Max-Age'] == '86400'
assert result.headers['Access-Control-Allow-Origin'] == '*'
else:
assert 'Access-Control-Allow-Methods' not in result.headers
assert 'Access-Control-Allow-Headers' not in result.headers
assert 'Access-Control-Max-Age' not in result.headers
assert 'Access-Control-Expose-Headers' not in result.headers
assert 'Access-Control-Allow-Origin' not in result.headers


@pytest.fixture(scope='function')
def make_cors_client(asgi, util):
Expand Down
16 changes: 16 additions & 0 deletions tests/test_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,3 +617,19 @@ def test_file_closed(client, patch_open):

assert patch_open.current_file is not None
assert patch_open.current_file.closed


def test_options_request(util, asgi, patch_open):
patch_open()
app = util.create_app(asgi, cors_enable=True)
app.add_static_route('/static', '/var/www/statics')
client = testing.TestClient(app)

resp = client.simulate_options(
path='/static/foo/bar.txt',
headers={'Origin': 'localhost', 'Access-Control-Request-Method': 'GET'},
)
assert resp.status_code == 200
assert resp.text == ''
assert int(resp.headers['Content-Length']) == 0
assert resp.headers['Access-Control-Allow-Methods'] == 'GET'