Skip to content

Commit 67798f7

Browse files
Cross-Origin Resource Sharing (CORS) support (Fixes miguelgrinberg#45)
1 parent ea6766c commit 67798f7

File tree

6 files changed

+325
-0
lines changed

6 files changed

+325
-0
lines changed

docs/api.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ API Reference
5252
.. automodule:: microdot_session
5353
:members:
5454

55+
``microdot_cors`` module
56+
------------------------
57+
58+
.. automodule:: microdot_cors
59+
:members:
60+
5561
``microdot_websocket`` module
5662
------------------------------
5763

docs/extensions.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,42 @@ Example::
208208
delete_session(req)
209209
return redirect('/')
210210

211+
Cross-Origin Resource Sharing (CORS)
212+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
213+
214+
.. list-table::
215+
:align: left
216+
217+
* - Compatibility
218+
- | CPython & MicroPython
219+
220+
* - Required Microdot source files
221+
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_
222+
| `microdot_cors.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_cors.py>`_
223+
224+
* - Required external dependencies
225+
- | None
226+
227+
* - Examples
228+
- | `cors.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/cors/cors.py>`_
229+
230+
The CORS extension provides support for `Cross-Origin Resource Sharing
231+
(CORS) <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>`_. CORS is a
232+
mechanism that allows web applications running on different origins to access
233+
resources from each other. For example, a web application running on
234+
``https://example.com`` can access resources from ``https://api.example.com``.
235+
236+
To enable CORS support, create an instance of the
237+
:class:`CORS <microdot_cors.CORS>` class and configure the desired options.
238+
Example::
239+
240+
from microdot import Microdot
241+
from microdot_cors import CORS
242+
243+
app = Microdot()
244+
cors = CORS(app, allowed_origins=['https://example.com'],
245+
allow_credentials=True)
246+
211247
WebSocket Support
212248
~~~~~~~~~~~~~~~~~
213249

examples/cors/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This directory contains Cross-Origin Resource Sharing (CORS) examples.

examples/cors/app.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from microdot import Microdot
2+
from microdot_cors import CORS
3+
4+
app = Microdot()
5+
CORS(app, allowed_origins=['https://example.org'], allow_credentials=True)
6+
7+
8+
@app.route('/')
9+
def index(request):
10+
return 'Hello World!'
11+
12+
13+
if __name__ == '__main__':
14+
app.run()

src/microdot_cors.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
class CORS:
2+
"""Add CORS headers to HTTP responses.
3+
4+
:param app: The application to add CORS headers to.
5+
:param allowed_origins: A list of origins that are allowed to make
6+
cross-site requests. If set to '*', all origins are
7+
allowed.
8+
:param allow_credentials: If set to True, the
9+
``Access-Control-Allow-Credentials`` header will
10+
be set to ``true`` to indicate to the browser
11+
that it can expose cookies and authentication
12+
headers.
13+
:param allowed_methods: A list of methods that are allowed to be used when
14+
making cross-site requests. If not set, all methods
15+
are allowed.
16+
:param expose_headers: A list of headers that the browser is allowed to
17+
exposed.
18+
:param allowed_headers: A list of headers that are allowed to be used when
19+
making cross-site requests. If not set, all headers
20+
are allowed.
21+
:param max_age: The maximum amount of time in seconds that the browser
22+
should cache the results of a preflight request.
23+
:param handle_cors: If set to False, CORS headers will not be added to
24+
responses. This can be useful if you want to add CORS
25+
headers manually.
26+
"""
27+
def __init__(self, app=None, allowed_origins=None, allow_credentials=False,
28+
allowed_methods=None, expose_headers=None,
29+
allowed_headers=None, max_age=None, handle_cors=True):
30+
self.allowed_origins = allowed_origins
31+
self.allow_credentials = allow_credentials
32+
self.allowed_methods = allowed_methods
33+
self.expose_headers = expose_headers
34+
self.allowed_headers = None if allowed_headers is None \
35+
else [h.lower() for h in allowed_headers]
36+
self.max_age = max_age
37+
if app is not None:
38+
self.initialize(app, handle_cors=handle_cors)
39+
40+
def initialize(self, app, handle_cors=True):
41+
"""Initialize the CORS object for the given application.
42+
43+
:param app: The application to add CORS headers to.
44+
:param handle_cors: If set to False, CORS headers will not be added to
45+
responses. This can be useful if you want to add
46+
CORS headers manually.
47+
"""
48+
self.default_options_handler = app.options_handler
49+
if handle_cors:
50+
app.options_handler = self.options_handler
51+
app.after_request(self.after_request)
52+
app.after_error_request(self.after_request)
53+
54+
def options_handler(self, request):
55+
headers = self.default_options_handler(request)
56+
headers.update(self.get_cors_headers(request))
57+
return headers
58+
59+
def get_cors_headers(self, request):
60+
"""Return a dictionary of CORS headers to add to a given request.
61+
62+
:param request: The request to add CORS headers to.
63+
"""
64+
cors_headers = {}
65+
origin = request.headers.get('Origin')
66+
if self.allowed_origins == '*':
67+
cors_headers['Access-Control-Allow-Origin'] = origin or '*'
68+
if origin:
69+
cors_headers['Vary'] = 'Origin'
70+
elif origin in (self.allowed_origins or []):
71+
cors_headers['Access-Control-Allow-Origin'] = origin
72+
cors_headers['Vary'] = 'Origin'
73+
if self.allow_credentials and \
74+
'Access-Control-Allow-Origin' in cors_headers:
75+
cors_headers['Access-Control-Allow-Credentials'] = 'true'
76+
if self.expose_headers:
77+
cors_headers['Access-Control-Expose-Headers'] = \
78+
', '.join(self.expose_headers)
79+
80+
if request.method == 'OPTIONS':
81+
# handle preflight request
82+
if self.max_age:
83+
cors_headers['Access-Control-Max-Age'] = str(self.max_age)
84+
85+
method = request.headers.get('Access-Control-Request-Method')
86+
if method:
87+
method = method.upper()
88+
if self.allowed_methods is None or \
89+
method in self.allowed_methods:
90+
cors_headers['Access-Control-Allow-Methods'] = method
91+
92+
headers = request.headers.get('Access-Control-Request-Headers')
93+
if headers:
94+
if self.allowed_headers is None:
95+
cors_headers['Access-Control-Allow-Headers'] = headers
96+
else:
97+
headers = [h.strip() for h in headers.split(',')]
98+
headers = [h for h in headers
99+
if h.lower() in self.allowed_headers]
100+
cors_headers['Access-Control-Allow-Headers'] = \
101+
', '.join(headers)
102+
103+
return cors_headers
104+
105+
def after_request(self, request, response):
106+
saved_vary = response.headers.get('Vary')
107+
response.headers.update(self.get_cors_headers(request))
108+
if saved_vary and saved_vary != response.headers.get('Vary'):
109+
response.headers['Vary'] = (
110+
saved_vary + ', ' + response.headers['Vary'])

tests/test_cors.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import unittest
2+
from microdot import Microdot
3+
from microdot_test_client import TestClient
4+
from microdot_cors import CORS
5+
6+
7+
class TestCORS(unittest.TestCase):
8+
def test_origin(self):
9+
app = Microdot()
10+
cors = CORS(allowed_origins=['https://example.com'],
11+
allow_credentials=True)
12+
cors.initialize(app)
13+
14+
@app.get('/')
15+
def index(req):
16+
return 'foo'
17+
18+
client = TestClient(app)
19+
res = client.get('/')
20+
self.assertEqual(res.status_code, 200)
21+
self.assertFalse('Access-Control-Allow-Origin' in res.headers)
22+
self.assertFalse('Access-Control-Allow-Credentials' in res.headers)
23+
self.assertFalse('Vary' in res.headers)
24+
25+
res = client.get('/', headers={'Origin': 'https://example.com'})
26+
self.assertEqual(res.status_code, 200)
27+
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
28+
'https://example.com')
29+
self.assertEqual(res.headers['Access-Control-Allow-Credentials'],
30+
'true')
31+
self.assertEqual(res.headers['Vary'], 'Origin')
32+
33+
cors.allow_credentials = False
34+
35+
res = client.get('/foo', headers={'Origin': 'https://example.com'})
36+
self.assertEqual(res.status_code, 404)
37+
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
38+
'https://example.com')
39+
self.assertFalse('Access-Control-Allow-Credentials' in res.headers)
40+
self.assertEqual(res.headers['Vary'], 'Origin')
41+
42+
res = client.get('/', headers={'Origin': 'https://bad.com'})
43+
self.assertEqual(res.status_code, 200)
44+
self.assertFalse('Access-Control-Allow-Origin' in res.headers)
45+
self.assertFalse('Access-Control-Allow-Credentials' in res.headers)
46+
self.assertFalse('Vary' in res.headers)
47+
48+
def test_all_origins(self):
49+
app = Microdot()
50+
CORS(app, allowed_origins='*', expose_headers=['X-Test', 'X-Test2'])
51+
52+
@app.get('/')
53+
def index(req):
54+
return 'foo'
55+
56+
@app.get('/foo')
57+
def foo(req):
58+
return 'foo', {'Vary': 'X-Foo, X-Bar'}
59+
60+
client = TestClient(app)
61+
res = client.get('/')
62+
self.assertEqual(res.status_code, 200)
63+
self.assertEqual(res.headers['Access-Control-Allow-Origin'], '*')
64+
self.assertFalse('Vary' in res.headers)
65+
self.assertEqual(res.headers['Access-Control-Expose-Headers'],
66+
'X-Test, X-Test2')
67+
68+
res = client.get('/', headers={'Origin': 'https://example.com'})
69+
self.assertEqual(res.status_code, 200)
70+
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
71+
'https://example.com')
72+
self.assertEqual(res.headers['Vary'], 'Origin')
73+
self.assertEqual(res.headers['Access-Control-Expose-Headers'],
74+
'X-Test, X-Test2')
75+
76+
res = client.get('/bad', headers={'Origin': 'https://example.com'})
77+
self.assertEqual(res.status_code, 404)
78+
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
79+
'https://example.com')
80+
self.assertEqual(res.headers['Vary'], 'Origin')
81+
self.assertEqual(res.headers['Access-Control-Expose-Headers'],
82+
'X-Test, X-Test2')
83+
84+
res = client.get('/foo', headers={'Origin': 'https://example.com'})
85+
self.assertEqual(res.status_code, 200)
86+
self.assertEqual(res.headers['Vary'], 'X-Foo, X-Bar, Origin')
87+
88+
def test_cors_preflight(self):
89+
app = Microdot()
90+
CORS(app, allowed_origins='*')
91+
92+
@app.route('/', methods=['GET', 'POST'])
93+
def index(req):
94+
return 'foo'
95+
96+
client = TestClient(app)
97+
res = client.request('OPTIONS', '/', headers={
98+
'Origin': 'https://example.com',
99+
'Access-Control-Request-Method': 'POST',
100+
'Access-Control-Request-Headers': 'X-Test, X-Test2'})
101+
self.assertEqual(res.status_code, 200)
102+
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
103+
'https://example.com')
104+
self.assertFalse('Access-Control-Max-Age' in res.headers)
105+
self.assertEqual(res.headers['Access-Control-Allow-Methods'], 'POST')
106+
self.assertEqual(res.headers['Access-Control-Allow-Headers'],
107+
'X-Test, X-Test2')
108+
109+
res = client.request('OPTIONS', '/', headers={
110+
'Origin': 'https://example.com'})
111+
self.assertEqual(res.status_code, 200)
112+
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
113+
'https://example.com')
114+
self.assertFalse('Access-Control-Max-Age' in res.headers)
115+
self.assertFalse('Access-Control-Allow-Methods' in res.headers)
116+
self.assertFalse('Access-Control-Allow-Headers' in res.headers)
117+
118+
def test_cors_preflight_with_options(self):
119+
app = Microdot()
120+
CORS(app, allowed_origins='*', max_age=3600, allowed_methods=['POST'],
121+
allowed_headers=['X-Test'])
122+
123+
@app.route('/', methods=['GET', 'POST'])
124+
def index(req):
125+
return 'foo'
126+
127+
client = TestClient(app)
128+
res = client.request('OPTIONS', '/', headers={
129+
'Origin': 'https://example.com',
130+
'Access-Control-Request-Method': 'POST',
131+
'Access-Control-Request-Headers': 'X-Test, X-Test2'})
132+
self.assertEqual(res.status_code, 200)
133+
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
134+
'https://example.com')
135+
self.assertEqual(res.headers['Access-Control-Max-Age'], '3600')
136+
self.assertEqual(res.headers['Access-Control-Allow-Methods'], 'POST')
137+
self.assertEqual(res.headers['Access-Control-Allow-Headers'], 'X-Test')
138+
139+
res = client.request('OPTIONS', '/', headers={
140+
'Origin': 'https://example.com',
141+
'Access-Control-Request-Method': 'GET'})
142+
self.assertEqual(res.status_code, 200)
143+
self.assertFalse('Access-Control-Allow-Methods' in res.headers)
144+
self.assertFalse('Access-Control-Allow-Headers' in res.headers)
145+
146+
def test_cors_disabled(self):
147+
app = Microdot()
148+
CORS(app, allowed_origins='*', handle_cors=False)
149+
150+
@app.get('/')
151+
def index(req):
152+
return 'foo'
153+
154+
client = TestClient(app)
155+
res = client.get('/')
156+
self.assertEqual(res.status_code, 200)
157+
self.assertFalse('Access-Control-Allow-Origin' in res.headers)
158+
self.assertFalse('Vary' in res.headers)

0 commit comments

Comments
 (0)