Skip to content

Commit 42bedc6

Browse files
committed
feat: improve cors header to properly handle missing allow headers
The static resource will now properly supports CORS requests. Fixes: #2325
1 parent 540927f commit 42bedc6

File tree

6 files changed

+109
-13
lines changed

6 files changed

+109
-13
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
The :class:`~CORSMiddleware` now properly handles the missing ``Allow``
2+
header case, by denying the preflight CORS request.
3+
The static resource has been updated to properly support CORS request,
4+
by allowing GET requests.

falcon/app.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,15 @@ def add_sink(self, sink: SinkCallable, prefix: SinkPrefix = r'/') -> None:
759759
impractical. For example, you might use a sink to create a smart
760760
proxy that forwards requests to one or more backend services.
761761
762+
Note:
763+
To support CORS preflight requests when using the default CORS middleware,
764+
either by setting ``App.cors_enable=True`` or by adding the
765+
:class:`~.CORSMiddleware` to the ``App.middleware``, the sink should
766+
set the ``Allow`` header in the request to the allowed
767+
method values when serving an ``OPTIONS`` request. If the ``Allow`` header
768+
is missing from the response, the default CORS middleware will deny the
769+
preflight request.
770+
762771
Args:
763772
sink (callable): A callable taking the form ``func(req, resp, **kwargs)``.
764773

falcon/middleware.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ class CORSMiddleware(object):
1717
* https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
1818
* https://www.w3.org/TR/cors/#resource-processing-model
1919
20+
Note:
21+
Falcon will automatically add OPTIONS responders if they are missing from the
22+
responder instances added to the routes. When providing a custom ``on_options``
23+
method, the ``Allow`` headers in the response should be set to the allowed
24+
method values. If the ``Allow`` header is missing from the response,
25+
this middleware will deny the preflight request.
26+
27+
This is also valid when using a sink function.
28+
2029
Keyword Arguments:
2130
allow_origins (Union[str, Iterable[str]]): List of origins to allow (case
2231
sensitive). The string ``'*'`` acts as a wildcard, matching every origin.
@@ -120,9 +129,17 @@ def process_response(
120129
'Access-Control-Request-Headers', default='*'
121130
)
122131

123-
resp.set_header('Access-Control-Allow-Methods', str(allow))
124-
resp.set_header('Access-Control-Allow-Headers', allow_headers)
125-
resp.set_header('Access-Control-Max-Age', '86400') # 24 hours
132+
if allow is None:
133+
# there is no allow set, remove all access control headers
134+
resp.delete_header('Access-Control-Allow-Methods')
135+
resp.delete_header('Access-Control-Allow-Headers')
136+
resp.delete_header('Access-Control-Max-Age')
137+
resp.delete_header('Access-Control-Expose-Headers')
138+
resp.delete_header('Access-Control-Allow-Origin')
139+
else:
140+
resp.set_header('Access-Control-Allow-Methods', allow)
141+
resp.set_header('Access-Control-Allow-Headers', allow_headers)
142+
resp.set_header('Access-Control-Max-Age', '86400') # 24 hours
126143

127144
async def process_response_async(self, *args: Any) -> None:
128145
self.process_response(*args)

falcon/routing/static.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ def match(self, path: str) -> bool:
183183
def __call__(self, req: Request, resp: Response, **kw: Any) -> None:
184184
"""Resource responder for this route."""
185185
assert not kw
186+
if req.method == 'OPTIONS':
187+
# it's likely a CORS request. Set the allow header to the appropriate value.
188+
resp.set_header('Allow', 'GET')
189+
resp.set_header('Content-Length', '0')
190+
return
191+
186192
without_prefix = req.path[len(self._prefix) :]
187193

188194
# NOTE(kgriffs): Check surrounding whitespace and strip trailing
@@ -247,9 +253,9 @@ class StaticRouteAsync(StaticRoute):
247253

248254
async def __call__(self, req: asgi.Request, resp: asgi.Response, **kw: Any) -> None: # type: ignore[override]
249255
super().__call__(req, resp, **kw)
250-
251-
# NOTE(kgriffs): Fixup resp.stream so that it is non-blocking
252-
resp.stream = _AsyncFileReader(resp.stream) # type: ignore[assignment,arg-type]
256+
if resp.stream is not None: # None when in an option request
257+
# NOTE(kgriffs): Fixup resp.stream so that it is non-blocking
258+
resp.stream = _AsyncFileReader(resp.stream) # type: ignore[assignment,arg-type]
253259

254260

255261
class _AsyncFileReader:

tests/test_cors_middleware.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from pathlib import Path
2+
13
import pytest
24

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

89-
@pytest.mark.xfail(reason='will be fixed in 2325')
90-
def test_enabled_cors_handles_preflighting_custom_option(self, cors_client):
91+
def test_enabled_cors_handles_preflight_custom_option(self, cors_client):
9192
cors_client.app.add_route('/', CORSOptionsResource())
9293
result = cors_client.simulate_options(
9394
headers=(
@@ -97,11 +98,10 @@ def test_enabled_cors_handles_preflighting_custom_option(self, cors_client):
9798
)
9899
)
99100
assert 'Access-Control-Allow-Methods' not in result.headers
100-
assert (
101-
result.headers['Access-Control-Allow-Headers']
102-
== 'X-PINGOTHER, Content-Type'
103-
)
104-
assert result.headers['Access-Control-Max-Age'] == '86400'
101+
assert 'Access-Control-Allow-Headers' not in result.headers
102+
assert 'Access-Control-Max-Age' not in result.headers
103+
assert 'Access-Control-Expose-Headers' not in result.headers
104+
assert 'Access-Control-Allow-Origin' not in result.headers
105105

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

120+
def test_enabled_cors_static_route(self, cors_client):
121+
cors_client.app.add_static_route('/static', Path(__file__).parent)
122+
result = cors_client.simulate_options(
123+
f'/static/{Path(__file__).name}',
124+
headers=(
125+
('Origin', 'localhost'),
126+
('Access-Control-Request-Method', 'GET'),
127+
),
128+
)
129+
130+
assert result.headers['Access-Control-Allow-Methods'] == 'GET'
131+
assert result.headers['Access-Control-Allow-Headers'] == '*'
132+
assert result.headers['Access-Control-Max-Age'] == '86400'
133+
assert result.headers['Access-Control-Allow-Origin'] == '*'
134+
135+
@pytest.mark.parametrize('support_options', [True, False])
136+
def test_enabled_cors_sink_route(self, cors_client, support_options):
137+
def my_sink(req, resp):
138+
if req.method == 'OPTIONS' and support_options:
139+
resp.set_header('ALLOW', 'GET')
140+
else:
141+
resp.text = 'my sink'
142+
143+
cors_client.app.add_sink(my_sink, '/sink')
144+
result = cors_client.simulate_options(
145+
'/sink/123',
146+
headers=(
147+
('Origin', 'localhost'),
148+
('Access-Control-Request-Method', 'GET'),
149+
),
150+
)
151+
152+
if support_options:
153+
assert result.headers['Access-Control-Allow-Methods'] == 'GET'
154+
assert result.headers['Access-Control-Allow-Headers'] == '*'
155+
assert result.headers['Access-Control-Max-Age'] == '86400'
156+
assert result.headers['Access-Control-Allow-Origin'] == '*'
157+
else:
158+
assert 'Access-Control-Allow-Methods' not in result.headers
159+
assert 'Access-Control-Allow-Headers' not in result.headers
160+
assert 'Access-Control-Max-Age' not in result.headers
161+
assert 'Access-Control-Expose-Headers' not in result.headers
162+
assert 'Access-Control-Allow-Origin' not in result.headers
163+
120164

121165
@pytest.fixture(scope='function')
122166
def make_cors_client(asgi, util):

tests/test_static.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,3 +617,19 @@ def test_file_closed(client, patch_open):
617617

618618
assert patch_open.current_file is not None
619619
assert patch_open.current_file.closed
620+
621+
622+
def test_options_request(util, asgi, patch_open):
623+
patch_open()
624+
app = util.create_app(asgi, cors_enable=True)
625+
app.add_static_route('/static', '/var/www/statics')
626+
client = testing.TestClient(app)
627+
628+
resp = client.simulate_options(
629+
path='/static/foo/bar.txt',
630+
headers={'Origin': 'localhost', 'Access-Control-Request-Method': 'GET'},
631+
)
632+
assert resp.status_code == 200
633+
assert resp.text == ''
634+
assert int(resp.headers['Content-Length']) == 0
635+
assert resp.headers['Access-Control-Allow-Methods'] == 'GET'

0 commit comments

Comments
 (0)