├── sanic_cors ├── version.py ├── __init__.py ├── decorator.py ├── core.py └── extension.py ├── setup.cfg ├── requirements.txt ├── requirements-dev.txt ├── docs ├── index.rst ├── api.rst ├── Makefile └── conf.py ├── MANIFEST.in ├── tests ├── __init__.py ├── core │ ├── __init__.py │ ├── test_override_headers.py │ └── helper_tests.py ├── extension │ ├── __init__.py │ └── test_app_extension.py ├── decorator │ ├── __init__.py │ ├── test_expose_headers.py │ ├── test_duplicate_headers.py │ ├── test_max_age.py │ ├── test_credentials.py │ ├── test_methods.py │ ├── test_w3.py │ ├── test_options.py │ ├── test_vary_header.py │ ├── test_allow_headers.py │ ├── test_origins.py │ └── test_exception_interception.py └── base_test.py ├── .gitignore ├── LICENSE ├── .travis.yml ├── setup.py ├── examples ├── view_based_example.py ├── blueprints_based_example.py ├── sanic_ext_example.py └── app_based_example.py ├── README.rst └── CHANGELOG.md /sanic_cors/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.2.0' 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = true 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | sanic>=21.9.3 2 | packaging>=21.3 -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=2.3.0,<=3.2.1 2 | sanic-testing>=0.8.2 3 | nose 4 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | .. toctree:: 4 | :hidden: 5 | :name: mastertoc 6 | 7 | self 8 | api 9 | 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include README.rst 3 | include requirements.txt 4 | include LICENSE 5 | 6 | recursive-include docs * 7 | recursive-include examples *.py 8 | recursive-include tests *.py 9 | 10 | prune docs/_build 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Tests for the shared sanic.ext.cores.core 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | -------------------------------------------------------------------------------- /tests/extension/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Tests particular to sanic_cors.CORS 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | -------------------------------------------------------------------------------- /tests/decorator/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Tests particular to sanic_cors.cross_origin 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtualenv 2 | ENV 3 | env 4 | .Python 5 | include 6 | lib 7 | 8 | # Python Packages 9 | *.egg 10 | *.egg-info 11 | bin 12 | build 13 | dist 14 | sdist 15 | eggs 16 | parts 17 | var 18 | develop-eggs 19 | .installed.cfg 20 | 21 | docs/.build 22 | docs/_build 23 | 24 | # Pip Installer Log 25 | pip-log.txt 26 | 27 | # Unit test / coverage 28 | .coverage 29 | .tox 30 | 31 | # Mac OS X 32 | .DS_Store 33 | 34 | # Generated files 35 | *.[oa] 36 | *.py[co] 37 | *.rbc 38 | 39 | #Temporary Files 40 | *.py~ 41 | *.html~ 42 | .*.sw* 43 | 44 | .idea/ 45 | .directory 46 | venv36/ 47 | venv35/ 48 | .venv/ -------------------------------------------------------------------------------- /sanic_cors/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | sanic_cors 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2022 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | from .decorator import cross_origin 12 | from .extension import CORS 13 | from .version import __version__ 14 | 15 | __all__ = ['CORS', 'cross_origin'] 16 | 17 | # Set default logging handler to avoid "No handler found" warnings. 18 | import logging 19 | from logging import NullHandler 20 | 21 | # Set initial level to WARN. Users must manually enable logging for 22 | # sanic_cors to see our logging. 23 | rootlogger = logging.getLogger(__name__) 24 | rootlogger.addHandler(NullHandler()) 25 | 26 | if rootlogger.level == logging.NOTSET: 27 | rootlogger.setLevel(logging.WARN) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Cory Dolphin, (Sanic-CORS port by Ashley Sommer) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /tests/core/test_override_headers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | 6 | Sanic-Cors tests module 7 | """ 8 | 9 | from ..base_test import SanicCorsTestCase 10 | #from sanic import Sanic, Response 11 | from sanic import Sanic 12 | from sanic.response import HTTPResponse 13 | 14 | from sanic_cors import * 15 | from sanic_cors.core import * 16 | 17 | class ResponseHeadersOverrideTestCaseIntegration(SanicCorsTestCase): 18 | def setUp(self): 19 | self.app = Sanic("test_override_headers") 20 | CORS(self.app) 21 | 22 | @self.app.route('/', methods=['GET', 'HEAD', 'OPTIONS']) 23 | def index(request): 24 | return HTTPResponse(body='Welcome', headers={"custom": "dictionary"}) 25 | 26 | 27 | def test_override_headers(self): 28 | ''' 29 | Ensure we work even if response.headers is set to something other than a MultiDict. 30 | ''' 31 | for resp in self.iter_responses('/'): 32 | self.assertTrue(ACL_ORIGIN in resp.headers) 33 | 34 | if __name__ == "__main__": 35 | unittest.main() 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: bionic 3 | cache: 4 | - pip 5 | language: python 6 | python: 7 | - '3.7' 8 | env: 9 | - SANIC=21.3.1 SPTK=1.0.0 10 | install: 11 | - pip install -U setuptools pep8 coverage docutils pygments aiohttp sanic==$SANIC sanic-plugin-toolkit==$SPTK 12 | script: 13 | - coverage erase 14 | - nosetests --with-coverage --cover-package=sanic_cors 15 | - python setup.py clean build install 16 | after_success: 17 | - pep8 sanic_cors.py 18 | deploy: 19 | provider: pypi 20 | user: ashleysommer 21 | distributions: sdist bdist_wheel 22 | on: 23 | tags: true 24 | repo: ashleysommer/sanic-cors 25 | password: 26 | secure: cnwEYQjhEx0BOP1tdFBeFnKHUpDfZWLKkXf7N36+AOOEjBgK72D03DCsKcCt1iZJPv7KcUIK84vZzmTPBmdvGyxJNTBTJRyPpPXw1TkokxFVwVutIrMCPJB6Nchd0ZLY8tarTNdEnR3y24AWfqP6EyD0Z4mRHHX9jwnNqxQi2aMsh2tVYMPk8IlfMSyxBM6R0rlh2Ahx5GWahuvHgmJBBovp9EED9vBHOUUIREI9rU0O2SPN1hxFXH5g6QFze3iaNKsxeXO02RfWSoK3dGCa5AYnwWeraX4nTCRA6t8l7mFKdwzjpUdklFm102StQGo4h+EqXsBEGVFhrwb/SSdPcwG/pdJsfiHBLeoPhb5amI1US68OBLVYz9m4p3fH/PSB6PjMER6WYc3dxtVjfSc8+8PSKwLeN3N5ucjZJ9OtcIiRt+yiOoh+HYfTpSCEeZpG6G+TnAhUD/WDFBTt8aQxjw2wTHdyY+D9+B8a5VHm2PvNr7tEUFW/JfpFSBM02pFc0I01Gvxn67sXDzx+6ccrtVCSs/QP8AfsYLTHu3tAYHE52appYPMQ+EkIHiQB7tKfVLr1FtvQNbJcN4q7OCS7v+R0lE08KcZ3COGK66jpKg9gORjrk6Uw7drGrW3fs1vB8cLXOeQAxz9NYJuhSl5FYww+0oGJ3QjzcK/ABxMVtB8= 27 | -------------------------------------------------------------------------------- /tests/decorator/test_expose_headers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | 6 | Sanic-Cors tests module 7 | """ 8 | 9 | from ..base_test import SanicCorsTestCase 10 | from sanic import Sanic 11 | from sanic.response import text 12 | from sanic_cors import * 13 | from sanic_cors.core import * 14 | 15 | 16 | class ExposeHeadersTestCase(SanicCorsTestCase): 17 | def setUp(self): 18 | self.app = Sanic(__name__.replace(".","-")) 19 | 20 | @self.app.route('/test_default', methods=['GET', 'HEAD', 'OPTIONS']) 21 | @cross_origin(self.app) 22 | def test_default(request): 23 | return text('Welcome!') 24 | 25 | @self.app.route('/test_override', methods=['GET', 'HEAD', 'OPTIONS']) 26 | @cross_origin(self.app, expose_headers=["X-My-Custom-Header", "X-Another-Custom-Header"]) 27 | def test_override(request): 28 | return text('Welcome!') 29 | 30 | def test_default(self): 31 | for resp in self.iter_responses('/test_default', origin='www.example.com'): 32 | self.assertTrue(resp.headers.get(ACL_EXPOSE_HEADERS) is None, 33 | "No Access-Control-Expose-Headers by default") 34 | 35 | def test_override(self): 36 | ''' The specified headers should be returned in the ACL_EXPOSE_HEADERS 37 | and correctly serialized if it is a list. 38 | ''' 39 | for resp in self.iter_responses('/test_override', origin='www.example.com'): 40 | self.assertEqual(resp.headers.get(ACL_EXPOSE_HEADERS), 41 | 'X-Another-Custom-Header, X-My-Custom-Header') 42 | 43 | if __name__ == "__main__": 44 | unittest.main() 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | setup 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2022 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | 12 | from setuptools import setup 13 | from os.path import join, dirname 14 | 15 | with open(join(dirname(__file__), 'sanic_cors/version.py'), 'r') as f: 16 | exec(f.read()) 17 | 18 | with open(join(dirname(__file__), 'requirements.txt'), 'r') as f: 19 | install_requires = f.read().split("\n") 20 | 21 | setup( 22 | name='Sanic-Cors', 23 | version=__version__, 24 | url='https://github.com/ashleysommer/sanic-cors', 25 | license='MIT', 26 | author='Ashley Sommer', 27 | author_email='ashleysommer@gmail.com', 28 | description="A Sanic extension adding a decorator for CORS support. Based on flask-cors by Cory Dolphin.", 29 | long_description=open('README.rst').read(), 30 | packages=['sanic_cors'], 31 | zip_safe=False, 32 | include_package_data=True, 33 | platforms='any', 34 | install_requires=install_requires, 35 | tests_require=[ 36 | 'nose' 37 | ], 38 | test_suite='nose.collector', 39 | classifiers=[ 40 | 'Environment :: Web Environment', 41 | 'Intended Audience :: Developers', 42 | 'License :: OSI Approved :: MIT License', 43 | 'Operating System :: OS Independent', 44 | 'Programming Language :: Python', 45 | 'Programming Language :: Python :: 3', 46 | 'Programming Language :: Python :: 3 :: Only', 47 | 'Programming Language :: Python :: 3.7', 48 | 'Programming Language :: Python :: 3.8', 49 | 'Programming Language :: Python :: 3.9', 50 | 'Programming Language :: Python :: 3.10', 51 | 'Programming Language :: Python :: Implementation :: CPython', 52 | 'Programming Language :: Python :: Implementation :: PyPy', 53 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 54 | 'Topic :: Software Development :: Libraries :: Python Modules' 55 | ] 56 | ) 57 | -------------------------------------------------------------------------------- /tests/decorator/test_duplicate_headers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | 6 | Sanic-Cors tests module 7 | """ 8 | 9 | from ..base_test import SanicCorsTestCase 10 | from sanic import Sanic 11 | from sanic.response import HTTPResponse 12 | try: 13 | # Sanic compat Header from Sanic v19.9.0 and above 14 | from sanic.compat import Header as CIMultiDict 15 | except ImportError: 16 | try: 17 | # Sanic server CIMultiDict from Sanic v0.8.0 and above 18 | from sanic.server import CIMultiDict 19 | except ImportError: 20 | raise RuntimeError("Your version of sanic does not support " 21 | "CIMultiDict") 22 | from sanic_cors import * 23 | from sanic_cors.core import * 24 | 25 | 26 | class AllowsMultipleHeaderEntries(SanicCorsTestCase): 27 | def setUp(self): 28 | self.app = Sanic(__name__.replace(".","-")) 29 | 30 | @self.app.route('/test_multiple_set_cookie_headers') 31 | @cross_origin(self.app) 32 | def test_multiple_set_cookie_headers(request): 33 | resp = HTTPResponse(body="Foo bar baz") 34 | resp.headers = CIMultiDict() 35 | resp.headers['set-cookie'] = 'foo' 36 | resp.headers.add('set-cookie', 'bar') 37 | return resp 38 | 39 | def test_multiple_set_cookie_headers(self): 40 | resp = self.get('/test_multiple_set_cookie_headers') 41 | try: 42 | # Sanic compat Header, in 19.9.0 and above 43 | cookies = set(resp.headers.get_all('set-cookie')) 44 | except AttributeError: 45 | try: 46 | # Sanic CIMultiDict, in v0.8.0 and above 47 | cookies = set(resp.headers.getall('set-cookie')) 48 | except AttributeError: 49 | try: 50 | # Sanic Test Client in Sanic 19.12.0 and above. 51 | cookies = set(resp.headers.getlist('set-cookie', 52 | split_commas=True)) 53 | except AttributeError: 54 | cookies = set(resp.headers.get('set-cookie').split(',')) 55 | cookies = set(x.strip().lower() for x in cookies) 56 | self.assertEqual(cookies, {'foo', 'bar'}) 57 | self.assertEqual(len(cookies), 2) 58 | 59 | if __name__ == "__main__": 60 | unittest.main() 61 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Docs 2 | ========== 3 | 4 | This package exposes a Sanic extension which by default enables CORS support on all routes, for all origins and methods. It allows parameterization of all CORS headers on a per-resource level. The package also contains a decorator, for those who prefer this approach. 5 | 6 | Extension 7 | ~~~~~~~~~ 8 | 9 | This is the suggested approach to enabling CORS. The default configuration 10 | will work well for most use cases. 11 | 12 | .. autoclass:: sanic_cors.CORS 13 | 14 | Decorator 15 | ~~~~~~~~~ 16 | 17 | If the `CORS` extension does not satisfy your needs, you may find the 18 | decorator useful. It shares options with the extension, and should be simple 19 | to use. 20 | 21 | .. autofunction:: sanic_cors.cross_origin 22 | 23 | 24 | Using `CORS` with cookies 25 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | 27 | By default, Sanic-CORS does not allow cookies to be submitted across sites, 28 | since it has potential security implications. If you wish to enable cross-site 29 | cookies, you may wish to add some sort of 30 | `CSRF `__ 31 | protection to keep you and your users safe. 32 | 33 | To allow cookies or authenticated requests to be made 34 | cross origins, simply set the `supports_credentials` option to `True`. E.G. 35 | 36 | .. code:: python 37 | 38 | 39 | from sanic import Sanic, session 40 | from sanic.response import text 41 | from sanic_cors import CORS 42 | 43 | app = Sanic(__name__) 44 | CORS(app, supports_credentials=True) 45 | 46 | @app.route("/") 47 | def hello_world(request): 48 | return text("Hello, %s" % session['username']) 49 | 50 | Using `CORS` with Blueprints 51 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 52 | 53 | Sanic-CORS supports blueprints out of the box. Simply pass a `blueprint` 54 | instance to the CORS extension, and everything will just work. 55 | 56 | .. literalinclude:: ../examples/blueprints_based_example.py 57 | :language: python 58 | :lines: 23- 59 | 60 | 61 | Examples 62 | ~~~~~~~~~ 63 | 64 | Using the `CORS` extension 65 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 66 | .. literalinclude:: ../examples/app_based_example.py 67 | :language: python 68 | :lines: 29- 69 | 70 | 71 | Using the `cross_origins` decorator 72 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 73 | 74 | .. literalinclude:: ../examples/view_based_example.py 75 | :language: python 76 | :lines: 27- 77 | -------------------------------------------------------------------------------- /tests/decorator/test_max_age.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | from datetime import timedelta 12 | import sys 13 | from ..base_test import SanicCorsTestCase 14 | from sanic import Sanic 15 | from sanic.response import text 16 | 17 | from sanic_cors import * 18 | from sanic_cors.core import * 19 | 20 | 21 | class MaxAgeTestCase(SanicCorsTestCase): 22 | def setUp(self): 23 | self.app = Sanic(__name__.replace(".","-")) 24 | 25 | @self.app.route('/defaults') 26 | @cross_origin(self.app) 27 | def defaults(request): 28 | return text('Should only return headers on OPTIONS') 29 | 30 | @self.app.route('/test_string', methods=['GET', 'OPTIONS']) 31 | @cross_origin(self.app, max_age=600) 32 | def test_string(request): 33 | return text('Open!') 34 | 35 | @self.app.route('/test_time_delta', methods=['GET', 'OPTIONS']) 36 | @cross_origin(self.app, max_age=timedelta(minutes=10)) 37 | def test_time_delta(request): 38 | return text('Open!') 39 | 40 | def test_defaults(self): 41 | ''' By default, no max-age headers should be returned 42 | ''' 43 | for resp in self.iter_responses('/defaults', origin='www.example.com'): 44 | self.assertFalse(ACL_MAX_AGE in resp.headers) 45 | 46 | def test_string(self): 47 | ''' If the methods parameter is defined, always return the allowed 48 | methods defined by the user. 49 | ''' 50 | resp = self.preflight('/test_string', origin='www.example.com') 51 | self.assertEqual(resp.headers.get(ACL_MAX_AGE), '600') 52 | 53 | def test_time_delta(self): 54 | ''' If the methods parameter is defined, always return the allowed 55 | methods defined by the user. 56 | ''' 57 | # timedelta.total_seconds is not available in older versions of Python 58 | if sys.version_info < (2, 7): 59 | return 60 | 61 | resp = self.preflight('/test_time_delta', origin='www.example.com') 62 | self.assertEqual(resp.headers.get(ACL_MAX_AGE), '600') 63 | 64 | 65 | if __name__ == "__main__": 66 | unittest.main() 67 | -------------------------------------------------------------------------------- /tests/decorator/test_credentials.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | 12 | from ..base_test import SanicCorsTestCase 13 | from sanic import Sanic 14 | from sanic.response import text 15 | 16 | from sanic_cors import * 17 | from sanic_cors.core import * 18 | 19 | 20 | class SupportsCredentialsCase(SanicCorsTestCase): 21 | def setUp(self): 22 | self.app = Sanic(__name__.replace(".","-")) 23 | 24 | @self.app.route('/test_credentials_supported') 25 | @cross_origin(self.app, supports_credentials=True) 26 | def test_credentials_supported(request): 27 | return text('Credentials!') 28 | 29 | @self.app.route('/test_credentials_unsupported') 30 | @cross_origin(self.app, supports_credentials=False) 31 | def test_credentials_unsupported(request): 32 | return text('Credentials!') 33 | 34 | @self.app.route('/test_default') 35 | @cross_origin(self.app) 36 | def test_default(request): 37 | return text('Open!') 38 | 39 | def test_credentials_supported(self): 40 | ''' The specified route should return the 41 | Access-Control-Allow-Credentials header. 42 | ''' 43 | resp = self.get('/test_credentials_supported', origin='www.example.com') 44 | self.assertEqual(resp.headers.get(ACL_CREDENTIALS), 'true') 45 | 46 | def test_default(self): 47 | ''' The default behavior should be to disallow credentials. 48 | ''' 49 | resp = self.get('/test_default', origin='www.example.com') 50 | self.assertFalse(ACL_CREDENTIALS in resp.headers) 51 | 52 | resp = self.get('/test_default') 53 | self.assertFalse(ACL_CREDENTIALS in resp.headers) 54 | 55 | def test_credentials_unsupported(self): 56 | ''' The default behavior should be to disallow credentials. 57 | ''' 58 | resp = self.get('/test_credentials_unsupported', origin='www.example.com') 59 | self.assertFalse(ACL_CREDENTIALS in resp.headers) 60 | 61 | resp = self.get('/test_credentials_unsupported') 62 | self.assertFalse(ACL_CREDENTIALS in resp.headers) 63 | 64 | if __name__ == "__main__": 65 | unittest.main() 66 | -------------------------------------------------------------------------------- /tests/decorator/test_methods.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | 12 | from ..base_test import SanicCorsTestCase 13 | from sanic import Sanic 14 | from sanic.response import text 15 | from sanic_cors import * 16 | from sanic_cors.core import * 17 | 18 | 19 | class MethodsCase(SanicCorsTestCase): 20 | def setUp(self): 21 | self.app = Sanic(__name__.replace(".","-")) 22 | 23 | @self.app.route('/defaults', methods=['GET', 'POST', 'HEAD', 'OPTIONS']) 24 | @cross_origin(self.app) 25 | def defaults(request): 26 | return text('Should only return headers on pre-flight OPTIONS request') 27 | 28 | @self.app.route('/test_methods_defined', methods=['POST', 'OPTIONS']) 29 | @cross_origin(self.app, methods=['POST']) 30 | def test_get(request): 31 | return text('Only allow POST') 32 | 33 | def test_defaults(self): 34 | ''' Access-Control-Allow-Methods headers should only be returned 35 | if the client makes an OPTIONS request. 36 | ''' 37 | 38 | self.assertFalse(ACL_METHODS in self.get('/defaults', origin='www.example.com').headers) 39 | self.assertFalse(ACL_METHODS in self.head('/defaults', origin='www.example.com').headers) 40 | res = self.preflight('/defaults', 'POST', origin='www.example.com') 41 | for method in ALL_METHODS: 42 | self.assertTrue(method in res.headers.get(ACL_METHODS)) 43 | 44 | def test_methods_defined(self): 45 | ''' If the methods parameter is defined, it should override the default 46 | methods defined by the user. 47 | ''' 48 | self.assertFalse(ACL_METHODS in self.get('/test_methods_defined').headers) 49 | self.assertFalse(ACL_METHODS in self.head('/test_methods_defined').headers) 50 | 51 | res = self.preflight('/test_methods_defined', 'POST', origin='www.example.com') 52 | self.assertTrue('POST' in res.headers.get(ACL_METHODS)) 53 | 54 | res = self.preflight('/test_methods_defined', 'PUT', origin='www.example.com') 55 | self.assertFalse(ACL_METHODS in res.headers) 56 | 57 | res = self.get('/test_methods_defined', origin='www.example.com') 58 | self.assertFalse(ACL_METHODS in res.headers) 59 | 60 | if __name__ == "__main__": 61 | unittest.main() 62 | -------------------------------------------------------------------------------- /tests/decorator/test_w3.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | 12 | from ..base_test import SanicCorsTestCase 13 | from sanic import Sanic 14 | from sanic.response import text 15 | from sanic_cors import * 16 | from sanic_cors.core import * 17 | 18 | 19 | class OriginsW3TestCase(SanicCorsTestCase): 20 | def setUp(self): 21 | self.app = Sanic(__name__.replace(".","-")) 22 | 23 | @self.app.route('/', methods=['GET', 'HEAD', 'OPTIONS']) 24 | @cross_origin(self.app, origins='*', send_wildcard=False, always_send=False) 25 | def allowOrigins(request): 26 | ''' This sets up sanic-cors to echo the request's `Origin` header, 27 | only if it is actually set. This behavior is most similar to 28 | the actual W3 specification, http://www.w3.org/TR/cors/ but 29 | is not the default because it is more common to use the 30 | wildcard configuration in order to support CDN caching. 31 | ''' 32 | return text('Welcome!') 33 | 34 | @self.app.route('/default-origins', methods=['GET', 'HEAD', 'OPTIONS']) 35 | @cross_origin(self.app, send_wildcard=False, always_send=False) 36 | def noWildcard(request): 37 | ''' With the default origins configuration, send_wildcard should 38 | still be respected. 39 | ''' 40 | return text('Welcome!') 41 | 42 | def test_wildcard_origin_header(self): 43 | ''' If there is an Origin header in the request, the 44 | Access-Control-Allow-Origin header should be echoed back. 45 | ''' 46 | example_origin = 'http://example.com' 47 | headers = {'Origin': example_origin} 48 | for resp in self.iter_responses('/', headers=headers): 49 | self.assertEqual( 50 | resp.headers.get(ACL_ORIGIN), 51 | example_origin 52 | ) 53 | 54 | def test_wildcard_no_origin_header(self): 55 | ''' If there is no Origin header in the request, the 56 | Access-Control-Allow-Origin header should not be included. 57 | ''' 58 | for resp in self.iter_responses('/'): 59 | self.assertTrue(ACL_ORIGIN not in resp.headers) 60 | 61 | def test_wildcard_default_origins(self): 62 | ''' If there is an Origin header in the request, the 63 | Access-Control-Allow-Origin header should be echoed back. 64 | ''' 65 | example_origin = 'http://example.com' 66 | headers = {'Origin': example_origin} 67 | for resp in self.iter_responses('/default-origins', headers=headers): 68 | self.assertEqual(resp.headers.get(ACL_ORIGIN), example_origin) 69 | 70 | 71 | if __name__ == "__main__": 72 | unittest.main() 73 | -------------------------------------------------------------------------------- /tests/core/helper_tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Tests for helper and utility methods 4 | TODO: move integration tests (e.g. all that test a full request cycle) 5 | into smaller, broken-up unit tests to simplify testing. 6 | ~~~~ 7 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 8 | origin resource sharing (CORS) using a simple decorator. 9 | 10 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 11 | :license: MIT, see LICENSE for more details. 12 | """ 13 | 14 | try: 15 | import unittest2 as unittest 16 | except ImportError: 17 | import unittest 18 | 19 | from sanic_cors.core import * 20 | 21 | class InternalsTestCase(unittest.TestCase): 22 | def test_try_match(self): 23 | self.assertFalse(try_match('www.com/foo', 'www.com/fo')) 24 | self.assertTrue(try_match('www.com/foo', 'www.com/fo*')) 25 | 26 | def test_flexible_str_str(self): 27 | self.assertEqual(flexible_str('Bar, Foo, Qux'), 'Bar, Foo, Qux') 28 | 29 | def test_flexible_str_set(self): 30 | self.assertEqual(flexible_str(set(['Foo', 'Bar', 'Qux'])), 31 | 'Bar, Foo, Qux') 32 | 33 | def test_serialize_options(self): 34 | try: 35 | serialize_options({ 36 | 'origins': r'*', 37 | 'allow_headers': True, 38 | 'supports_credentials': True, 39 | 'send_wildcard': True 40 | }) 41 | self.assertFalse(True, "A Value Error should have been raised.") 42 | except ValueError: 43 | pass 44 | 45 | def test_get_allow_headers_empty(self): 46 | options = serialize_options({'allow_headers': r'*'}) 47 | 48 | self.assertEqual(get_allow_headers(options, ''), None) 49 | self.assertEqual(get_allow_headers(options, None), None) 50 | 51 | def test_get_allow_headers_matching(self): 52 | options = serialize_options({'allow_headers': r'*'}) 53 | 54 | self.assertEqual(get_allow_headers(options, 'X-FOO'), 'X-FOO') 55 | self.assertEqual( 56 | get_allow_headers(options, 'X-Foo, X-Bar'), 57 | 'X-Bar, X-Foo' 58 | ) 59 | 60 | def test_get_allow_headers_matching_none(self): 61 | options = serialize_options({'allow_headers': r'X-SANIC-.*'}) 62 | 63 | self.assertEqual(get_allow_headers(options, 'X-SANIC-CORS'), 64 | 'X-SANIC-CORS') 65 | self.assertEqual( 66 | get_allow_headers(options, 'X-NOT-SANIC-CORS'), 67 | '' 68 | ) 69 | 70 | def test_parse_resources_sorted(self): 71 | resources = parse_resources({ 72 | '/foo': {'origins': 'http://foo.com'}, 73 | re.compile(r'/.*'): { 74 | 'origins': 'http://some-domain.com' 75 | }, 76 | re.compile(r'/api/v1/.*'): { 77 | 'origins': 'http://specific-domain.com' 78 | } 79 | }) 80 | 81 | self.assertEqual( 82 | [r[0] for r in resources], 83 | [re.compile(r'/api/v1/.*'), '/foo', re.compile(r'/.*')] 84 | ) 85 | 86 | def test_probably_regex(self): 87 | self.assertTrue(probably_regex("http://*.example.com")) 88 | self.assertTrue(probably_regex("*")) 89 | self.assertFalse(probably_regex("http://example.com")) 90 | self.assertTrue(probably_regex("http://[\w].example.com")) 91 | self.assertTrue(probably_regex("http://\w+.example.com")) 92 | self.assertTrue(probably_regex("https?://example.com")) 93 | -------------------------------------------------------------------------------- /tests/base_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | from sanic import Sanic 12 | try: 13 | import unittest2 as unittest 14 | except ImportError: 15 | import unittest 16 | from sanic_cors.core import * 17 | 18 | 19 | class SanicCorsTestCase(unittest.TestCase): 20 | 21 | def shortDescription(self): 22 | """ 23 | Get's the one liner description to be displayed. 24 | Source: 25 | http://erikzaadi.com/2012/09/13/inheritance-within-python-unit-tests/ 26 | """ 27 | doc = self.id()[self.id().rfind('.')+1:] 28 | return "%s.%s" % (self.__class__.__name__, doc) 29 | 30 | def iter_verbs(self, c): 31 | ''' A simple helper method to iterate through a range of 32 | HTTP Verbs and return the test_client bound instance, 33 | keeping writing our tests as DRY as possible. 34 | ''' 35 | for verb in ['get', 'head', 'options']: 36 | yield getattr(c, verb) 37 | 38 | def iter_responses(self, path, verbs=['get', 'head', 'options'], **kwargs): 39 | for verb in verbs: 40 | yield self._request(verb.lower(), path, **kwargs) 41 | 42 | def _request(self, verb, path, *args, **kwargs): 43 | _origin = kwargs.pop('origin', None) 44 | headers = kwargs.pop('headers', {}) 45 | if _origin: 46 | headers.update(Origin=_origin) 47 | #if not str(path).startswith("http") and not str(path).startswith("/"): 48 | # path = ''.join(['/', path]) 49 | 50 | try: 51 | c = self.test_client 52 | except AttributeError: 53 | c = self.test_client = self.app.test_client 54 | request, response = getattr(c, verb)(uri=path, debug=True, headers=headers, *args, **kwargs) 55 | return response 56 | 57 | def get(self, *args, **kwargs): 58 | return self._request('get', *args, **kwargs) 59 | 60 | def head(self, *args, **kwargs): 61 | return self._request('head', *args, **kwargs) 62 | 63 | def post(self, *args, **kwargs): 64 | return self._request('post', *args, **kwargs) 65 | 66 | def options(self, *args, **kwargs): 67 | return self._request('options', *args, **kwargs) 68 | 69 | def put(self, *args, **kwargs): 70 | return self._request('put', *args, **kwargs) 71 | 72 | def patch(self, *args, **kwargs): 73 | return self._request('patch', *args, **kwargs) 74 | 75 | def delete(self, *args, **kwargs): 76 | return self._request('delete', *args, **kwargs) 77 | 78 | def preflight(self, path, method='GET', cors_request_headers=None, json=True, **kwargs): 79 | kwargs['headers'] = kwargs.get('headers', {}) 80 | 81 | if cors_request_headers: 82 | kwargs['headers'].update({'Access-Control-Request-Headers': ', '.join(cors_request_headers)}) 83 | if json: 84 | kwargs['headers'].update({'Content-Type':'application/json'}) 85 | 86 | kwargs['headers'].update({'Access-Control-Request-Method': method}) 87 | 88 | return self.options(path, **kwargs) 89 | 90 | def assertHasACLOrigin(self, resp, origin=None): 91 | if origin is None: 92 | self.assertTrue(ACL_ORIGIN in resp.headers) 93 | else: 94 | self.assertTrue(resp.headers.get(ACL_ORIGIN) == origin) 95 | -------------------------------------------------------------------------------- /examples/view_based_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sanic-Cors example 3 | =================== 4 | This is a tiny Sanic Application demonstrating Sanic-Cors, making it simple 5 | to add cross origin support to your sanic app! 6 | 7 | :copyright: (c) 2022 by Ashley Sommer (based on flask-cors by Cory Dolphin). 8 | :license: MIT/X11, see LICENSE for more details. 9 | """ 10 | from sanic import Sanic 11 | from sanic.response import json, text 12 | import logging 13 | try: 14 | # The typical way to import sanic-cors 15 | from sanic_cors import cross_origin 16 | except ImportError: 17 | # Path hack allows examples to be run without installation. 18 | import os 19 | parentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 20 | os.sys.path.insert(0, parentdir) 21 | 22 | from sanic_cors import cross_origin 23 | 24 | 25 | app = Sanic('SanicCorsViewBasedExample') 26 | 27 | @app.route("/", methods=['GET', 'OPTIONS']) 28 | @cross_origin(app, automatic_options=True) 29 | def hello_world(request): 30 | ''' 31 | This view has CORS enabled for all domains, representing the simplest 32 | configuration of view-based decoration. The expected result is as 33 | follows: 34 | 35 | $ curl --include -X GET http://127.0.0.1:5000/ \ 36 | --header Origin:www.examplesite.com 37 | 38 | >> HTTP/1.0 200 OK 39 | Content-Type: text/html; charset=utf-8 40 | Content-Length: 184 41 | Access-Control-Allow-Origin: * 42 | Server: Werkzeug/0.9.6 Python/2.7.9 43 | Date: Sat, 31 Jan 2015 22:29:56 GMT 44 | 45 |

Hello CORS!

Read about my spec at the 46 | W3 Or, checkout my documentation 47 | on Github 48 | 49 | ''' 50 | return text('''

Hello CORS!

Read about my spec at the 51 | W3 Or, checkout my documentation 52 | on Github''') 53 | 54 | 55 | @app.route("/api/v1/users/create", methods=['GET', 'POST', 'OPTIONS']) 56 | @cross_origin(app, automatic_options=True, allow_headers=['Content-Type']) 57 | def cross_origin_json_post(request): 58 | ''' 59 | This view has CORS enabled for all domains, and allows browsers 60 | to send the Content-Type header, allowing cross domain AJAX POST 61 | requests. 62 | 63 | Browsers will first make a preflight request to verify that the resource 64 | allows cross-origin POSTs with a JSON Content-Type, which can be simulated 65 | as: 66 | $ curl --include -X OPTIONS http://127.0.0.1:5000/api/v1/users/create \ 67 | --header Access-Control-Request-Method:POST \ 68 | --header Access-Control-Request-Headers:Content-Type \ 69 | --header Origin:www.examplesite.com 70 | >> HTTP/1.0 200 OK 71 | Content-Type: text/html; charset=utf-8 72 | Allow: POST, OPTIONS 73 | Access-Control-Allow-Origin: * 74 | Access-Control-Allow-Headers: Content-Type 75 | Access-Control-Allow-Methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT 76 | Content-Length: 0 77 | Server: Werkzeug/0.9.6 Python/2.7.9 78 | Date: Sat, 31 Jan 2015 22:25:22 GMT 79 | 80 | 81 | $ curl --include -X POST http://127.0.0.1:5000/api/v1/users/create \ 82 | --header Content-Type:application/json \ 83 | --header Origin:www.examplesite.com 84 | 85 | 86 | >> HTTP/1.0 200 OK 87 | Content-Type: application/json 88 | Content-Length: 21 89 | Access-Control-Allow-Origin: * 90 | Server: Werkzeug/0.9.6 Python/2.7.9 91 | Date: Sat, 31 Jan 2015 22:25:04 GMT 92 | 93 | { 94 | "success": true 95 | } 96 | 97 | ''' 98 | 99 | return json({"success":True}) 100 | 101 | if __name__ == "__main__": 102 | app.run(debug=True) 103 | -------------------------------------------------------------------------------- /tests/decorator/test_options.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | 12 | from ..base_test import SanicCorsTestCase 13 | from sanic import Sanic 14 | from sanic.response import text 15 | from sanic_cors import * 16 | from sanic_cors.core import * 17 | 18 | 19 | class OptionsTestCase(SanicCorsTestCase): 20 | def setUp(self): 21 | self.app = Sanic(__name__.replace(".","-")) 22 | 23 | @self.app.route('/test_default', methods=['OPTIONS']) 24 | @cross_origin(self.app) 25 | def test_default(request): 26 | return text('Welcome!') 27 | 28 | @self.app.route('/test_async_default', methods=['GET', 'OPTIONS']) 29 | @cross_origin(self.app) 30 | async def test_async_default(request): 31 | return text('Async Welcome!') 32 | 33 | @self.app.route('/test_no_options_and_not_auto', methods=['GET', 'POST', 'PUT', 'DELETE']) 34 | @cross_origin(self.app, automatic_options=False) 35 | def test_no_options_and_not_auto(request): 36 | return text('Welcome!') 37 | 38 | @self.app.route('/test_options_and_not_auto', methods=['OPTIONS']) 39 | @cross_origin(self.app, automatic_options=False) 40 | def test_options_and_not_auto(request): 41 | return text('Welcome!') 42 | 43 | def test_defaults(self): 44 | ''' 45 | The default behavior should automatically provide OPTIONS 46 | and return CORS headers. 47 | ''' 48 | resp = self.options('/test_default', origin='http://foo.bar.com') 49 | self.assertEqual(resp.status, 200) 50 | self.assertTrue(ACL_ORIGIN in resp.headers) 51 | 52 | # TODO: this is duplicated (from flask-cors) 53 | resp = self.options('/test_default', origin='http://foo.bar.com') 54 | self.assertEqual(resp.status, 200) 55 | self.assertTrue(ACL_ORIGIN in resp.headers) 56 | 57 | resp = self.options('/test_async_default', origin='http://foo.bar.com') 58 | self.assertEqual(resp.status, 200) 59 | self.assertTrue(ACL_ORIGIN in resp.headers) 60 | 61 | resp = self.get('/test_async_default', origin='http://foo.bar.com') 62 | self.assertEqual(resp.status, 200) 63 | self.assertTrue(ACL_ORIGIN in resp.headers) 64 | self.assertEqual(resp.body, b"Async Welcome!") 65 | 66 | 67 | def test_no_options_and_not_auto(self): 68 | ''' 69 | If automatic_options is False, and the view func does not provide 70 | OPTIONS, then Sanic will throw a 405 Method not Allowed 71 | ''' 72 | resp = self.options('/test_no_options_and_not_auto') 73 | self.assertEqual(resp.status, 405) 74 | self.assertFalse(ACL_ORIGIN in resp.headers) 75 | 76 | resp = self.options('/test_no_options_and_not_auto', origin='http://foo.bar.com') 77 | self.assertEqual(resp.status, 405) 78 | self.assertFalse(ACL_ORIGIN in resp.headers) 79 | 80 | def test_options_and_not_auto(self): 81 | ''' 82 | If OPTIONS is in methods, and automatic_options is False, 83 | the view function must return a response. 84 | ''' 85 | resp = self.options('/test_options_and_not_auto', origin='http://foo.bar.com') 86 | self.assertEqual(resp.status, 200) 87 | self.assertTrue(ACL_ORIGIN in resp.headers) 88 | self.assertEqual(resp.body.decode("utf-8"), u"Welcome!") 89 | 90 | # TODO: This is duplicated (from flask-cors) 91 | resp = self.options('/test_options_and_not_auto', origin='http://foo.bar.com') 92 | self.assertEqual(resp.status, 200) 93 | self.assertTrue(ACL_ORIGIN in resp.headers) 94 | self.assertEqual(resp.body.decode("utf-8"), u"Welcome!") 95 | 96 | if __name__ == "__main__": 97 | unittest.main() 98 | -------------------------------------------------------------------------------- /examples/blueprints_based_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sanic-Cors example 3 | =================== 4 | This is a tiny Sanic Application demonstrating Sanic-Cors, making it simple 5 | to add cross origin support to your sanic app! 6 | 7 | :copyright: (c) 2022 by Ashley Sommer (based on flask-cors by Cory Dolphin). 8 | :license: MIT/X11, see LICENSE for more details. 9 | """ 10 | from sanic import Sanic, Blueprint 11 | from sanic.response import json, text 12 | import logging 13 | try: 14 | from sanic_cors import CORS # The typical way to import sanic-cors 15 | except ImportError: 16 | # Path hack allows examples to be run without installation. 17 | import os 18 | parentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | os.sys.path.insert(0, parentdir) 20 | 21 | from sanic_cors import CORS 22 | 23 | 24 | api_v1 = Blueprint(__name__, None) 25 | 26 | CORS(api_v1) # enable CORS on the API_v1 blue print 27 | 28 | @api_v1.route("/api/v1/users", methods=['GET', 'OPTIONS']) 29 | def list_users(request): 30 | ''' 31 | Since the path matches the regular expression r'/api/*', this resource 32 | automatically has CORS headers set. The expected result is as follows: 33 | 34 | $ curl --include -X GET http://127.0.0.1:5000/api/v1/users/ \ 35 | --header Origin:www.examplesite.com 36 | HTTP/1.0 200 OK 37 | Access-Control-Allow-Headers: Content-Type 38 | Access-Control-Allow-Origin: * 39 | Content-Length: 21 40 | Content-Type: application/json 41 | Date: Sat, 09 Aug 2014 00:26:41 GMT 42 | Server: Werkzeug/0.9.4 Python/2.7.8 43 | 44 | { 45 | "success": true 46 | } 47 | 48 | ''' 49 | return json({"user": "joe"}) 50 | 51 | 52 | @api_v1.route("/api/v1/users/create", methods=['POST', 'OPTIONS']) 53 | def create_user(request): 54 | ''' 55 | Since the path matches the regular expression r'/api/*', this resource 56 | automatically has CORS headers set. 57 | 58 | Browsers will first make a preflight request to verify that the resource 59 | allows cross-origin POSTs with a JSON Content-Type, which can be simulated 60 | as: 61 | $ curl --include -X OPTIONS http://127.0.0.1:5000/api/v1/users/create \ 62 | --header Access-Control-Request-Method:POST \ 63 | --header Access-Control-Request-Headers:Content-Type \ 64 | --header Origin:www.examplesite.com 65 | >> HTTP/1.0 200 OK 66 | Content-Type: text/html; charset=utf-8 67 | Allow: POST, OPTIONS 68 | Access-Control-Allow-Origin: * 69 | Access-Control-Allow-Headers: Content-Type 70 | Access-Control-Allow-Methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT 71 | Content-Length: 0 72 | Server: Werkzeug/0.9.6 Python/2.7.9 73 | Date: Sat, 31 Jan 2015 22:25:22 GMT 74 | 75 | 76 | $ curl --include -X POST http://127.0.0.1:5000/api/v1/users/create \ 77 | --header Content-Type:application/json \ 78 | --header Origin:www.examplesite.com 79 | 80 | 81 | >> HTTP/1.0 200 OK 82 | Content-Type: application/json 83 | Content-Length: 21 84 | Access-Control-Allow-Origin: * 85 | Server: Werkzeug/0.9.6 Python/2.7.9 86 | Date: Sat, 31 Jan 2015 22:25:04 GMT 87 | 88 | { 89 | "success": true 90 | } 91 | 92 | ''' 93 | return json({"success": True}) 94 | 95 | public_routes = Blueprint('public', None) 96 | 97 | @public_routes.route("/") 98 | def hello_world(request): 99 | ''' 100 | Since the path '/' does not match the regular expression r'/api/*', 101 | this route does not have CORS headers set. 102 | ''' 103 | return text('''

Hello CORS!

Read about my spec at the 104 | W3 Or, checkout my documentation 105 | on Github''') 106 | 107 | 108 | logging.basicConfig(level=logging.INFO) 109 | app = Sanic('SanicCorsBlueprintBasedExample') 110 | app.blueprint(api_v1) 111 | app.blueprint(public_routes) 112 | 113 | 114 | if __name__ == "__main__": 115 | app.run(debug=True) 116 | -------------------------------------------------------------------------------- /sanic_cors/decorator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | decorator 4 | ~~~~ 5 | This unit exposes a single decorator which should be used to wrap a 6 | Sanic route with. It accepts all parameters and options as 7 | the CORS extension. 8 | 9 | :copyright: (c) 2022 by Ashley Sommer (based on flask-cors by Cory Dolphin). 10 | :license: MIT, see LICENSE for more details. 11 | """ 12 | from functools import wraps 13 | 14 | from sanic.log import logger 15 | from .core import * 16 | from .extension import CORS 17 | 18 | 19 | def cross_origin(app, *args, **kwargs): 20 | """ 21 | This function is the decorator which is used to wrap a Sanic route with. 22 | In the simplest case, simply use the default parameters to allow all 23 | origins in what is the most permissive configuration. If this method 24 | modifies state or performs authentication which may be brute-forced, you 25 | should add some degree of protection, such as Cross Site Forgery 26 | Request protection. 27 | 28 | :param origins: 29 | The origin, or list of origins to allow requests from. 30 | The origin(s) may be regular expressions, case-sensitive strings, 31 | or else an asterisk 32 | 33 | Default : '*' 34 | :type origins: list, string or regex 35 | 36 | :param methods: 37 | The method or list of methods which the allowed origins are allowed to 38 | access for non-simple requests. 39 | 40 | Default : [GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE] 41 | :type methods: list or string 42 | 43 | :param expose_headers: 44 | The header or list which are safe to expose to the API of a CORS API 45 | specification. 46 | 47 | Default : None 48 | :type expose_headers: list or string 49 | 50 | :param allow_headers: 51 | The header or list of header field names which can be used when this 52 | resource is accessed by allowed origins. The header(s) may be regular 53 | expressions, case-sensitive strings, or else an asterisk. 54 | 55 | Default : '*', allow all headers 56 | :type allow_headers: list, string or regex 57 | 58 | :param supports_credentials: 59 | Allows users to make authenticated requests. If true, injects the 60 | `Access-Control-Allow-Credentials` header in responses. This allows 61 | cookies and credentials to be submitted across domains. 62 | 63 | :note: This option cannot be used in conjuction with a '*' origin 64 | 65 | Default : False 66 | :type supports_credentials: bool 67 | 68 | :param max_age: 69 | The maximum time for which this CORS request maybe cached. This value 70 | is set as the `Access-Control-Max-Age` header. 71 | 72 | Default : None 73 | :type max_age: timedelta, integer, string or None 74 | 75 | :param send_wildcard: If True, and the origins parameter is `*`, a wildcard 76 | `Access-Control-Allow-Origin` header is sent, rather than the 77 | request's `Origin` header. 78 | 79 | Default : False 80 | :type send_wildcard: bool 81 | 82 | :param vary_header: 83 | If True, the header Vary: Origin will be returned as per the W3 84 | implementation guidelines. 85 | 86 | Setting this header when the `Access-Control-Allow-Origin` is 87 | dynamically generated (e.g. when there is more than one allowed 88 | origin, and an Origin than '*' is returned) informs CDNs and other 89 | caches that the CORS headers are dynamic, and cannot be cached. 90 | 91 | If False, the Vary header will never be injected or altered. 92 | 93 | Default : True 94 | :type vary_header: bool 95 | 96 | :param automatic_options: 97 | Only applies to the `cross_origin` decorator. If True, Sanic-CORS will 98 | override Sanic's default OPTIONS handling to return CORS headers for 99 | OPTIONS requests. 100 | 101 | Default : True 102 | :type automatic_options: bool 103 | 104 | """ 105 | decorator_kwargs = kwargs 106 | decorator_args = args 107 | #_real_decorator = cors.decorate(app, *args, run_middleware=False, with_context=False, **kwargs) 108 | cors = CORS(app, no_startup=True) 109 | def wrapper(f): 110 | @wraps(f) 111 | async def inner(request, *args, **kwargs): 112 | return await cors.route_wrapper(f, request, app, args, kwargs, *decorator_args, **decorator_kwargs) 113 | 114 | logger.log(logging.DEBUG, "Enabled {:s} for cross_origin using options: {}".format(str(f), str(decorator_kwargs))) 115 | return inner 116 | 117 | return wrapper 118 | -------------------------------------------------------------------------------- /tests/decorator/test_vary_header.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | 12 | from ..base_test import SanicCorsTestCase 13 | from sanic import Sanic 14 | from sanic.response import HTTPResponse, text 15 | try: 16 | # Sanic compat Header from Sanic v19.9.0 and above 17 | from sanic.compat import Header as CIMultiDict 18 | except ImportError: 19 | try: 20 | # Sanic server CIMultiDict from Sanic v0.8.0 and above 21 | from sanic.server import CIMultiDict 22 | except ImportError: 23 | raise RuntimeError("Your version of sanic does not support " 24 | "CIMultiDict") 25 | 26 | from sanic_cors import * 27 | 28 | 29 | class VaryHeaderTestCase(SanicCorsTestCase): 30 | def setUp(self): 31 | self.app = Sanic(__name__.replace(".","-")) 32 | 33 | @self.app.route('/', methods=['GET', 'HEAD', 'OPTIONS']) 34 | @cross_origin(self.app) 35 | def wildcard(request): 36 | return text('Welcome!') 37 | 38 | @self.app.route('/test_consistent_origin', methods=['GET', 'HEAD', 'OPTIONS']) 39 | @cross_origin(self.app, origins='http://foo.com') 40 | def test_consistent(request): 41 | return text('Welcome!') 42 | 43 | @self.app.route('/test_vary', methods=['GET', 'HEAD', 'OPTIONS']) 44 | @cross_origin(self.app, origins=["http://foo.com", "http://bar.com"]) 45 | def test_vary(request): 46 | return text('Welcome!') 47 | 48 | @self.app.route('/test_existing_vary_headers') 49 | @cross_origin(self.app, origins=["http://foo.com", "http://bar.com"]) 50 | def test_existing_vary_headers(request): 51 | return HTTPResponse('', status=200, headers=CIMultiDict({'Vary': 'Accept-Encoding'})) 52 | 53 | def test_default(self): 54 | ''' 55 | By default, allow all domains, which means the Vary:Origin header 56 | should be set. 57 | ''' 58 | for resp in self.iter_responses('/', origin="http://foo.com"): 59 | self.assertTrue('Vary' in resp.headers) 60 | 61 | def test_consistent_origin(self): 62 | ''' 63 | If the Access-Control-Allow-Origin header will change dynamically, 64 | the Vary:Origin header should be set. 65 | ''' 66 | for resp in self.iter_responses('/test_consistent_origin', origin="http://foo.com"): 67 | self.assertFalse('Vary' in resp.headers) 68 | 69 | def test_varying_origin(self): 70 | ''' Resources that wish to enable themselves to be shared with 71 | multiple Origins but do not respond uniformly with "*" must 72 | in practice generate the Access-Control-Allow-Origin header 73 | dynamically in response to every request they wish to allow. 74 | 75 | As a consequence, authors of such resources should send a Vary: 76 | Origin HTTP header or provide other appropriate control directives 77 | to prevent caching of such responses, which may be inaccurate if 78 | re-used across-origins. 79 | ''' 80 | example_origin = 'http://foo.com' 81 | for resp in self.iter_responses('/test_vary', origin=example_origin): 82 | self.assertHasACLOrigin(resp) 83 | self.assertEqual(resp.headers.get('Vary'), 'Origin') 84 | 85 | def test_consistent_origin_concat(self): 86 | ''' 87 | If Sanic-Cors adds a Vary header and there is already a Vary 88 | header set, the headers should be combined and comma-separated. 89 | ''' 90 | 91 | resp = self.get('/test_existing_vary_headers', origin="http://foo.com") 92 | try: 93 | # Sanic compat Header, in 19.9.0 and above 94 | varys = set(resp.headers.get_all('Vary')) 95 | except AttributeError: 96 | try: 97 | # Sanic CIMultiDict, in v0.8.0 and above 98 | varys = set(resp.headers.getall('Vary')) 99 | except AttributeError: 100 | try: 101 | # Sanic Test Client in Sanic 19.12.0 and above. 102 | varys = set(resp.headers.getlist('Vary', 103 | split_commas=True)) 104 | except AttributeError: 105 | varys = set(resp.headers.get('Vary').split(',')) 106 | varys = set(x.strip().lower() for x in varys) 107 | 108 | self.assertEqual(varys, 109 | set(['origin', 'accept-encoding'])) 110 | 111 | if __name__ == "__main__": 112 | unittest.main() 113 | -------------------------------------------------------------------------------- /tests/decorator/test_allow_headers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | 6 | Sanic-Cors tests module 7 | """ 8 | 9 | from ..base_test import SanicCorsTestCase 10 | from sanic import Sanic 11 | from sanic.response import text 12 | from sanic_cors import * 13 | from sanic_cors.core import * 14 | 15 | class AllowHeadersTestCaseIntegration(SanicCorsTestCase): 16 | def setUp(self): 17 | self.app = Sanic(__name__.replace(".","-")) 18 | 19 | @self.app.route('/test_default', methods=['GET', 'HEAD', 'OPTIONS']) 20 | @cross_origin(self.app) 21 | def test_default(request): 22 | return text('Welcome!') 23 | 24 | @self.app.route('/test_allow_headers', methods=['GET', 'HEAD', 'OPTIONS']) 25 | @cross_origin(self.app, allow_headers=['X-Example-Header-B', 26 | 'X-Example-Header-A']) 27 | def test_allow_headers(request): 28 | return text('Welcome!') 29 | 30 | @self.app.route('/test_allow_headers_regex', methods=['GET', 'HEAD', 'OPTIONS']) 31 | @cross_origin(self.app, allow_headers=[r'X-COMPANY-.*']) 32 | def test_allow_headers_regex(request): 33 | return text('Welcome!') 34 | 35 | def test_default(self): 36 | for resp in self.iter_responses('/test_default'): 37 | self.assertTrue(resp.headers.get(ACL_ALLOW_HEADERS) is None, 38 | "Default should have no allowed headers") 39 | 40 | def test_allow_headers_no_request_headers(self): 41 | ''' 42 | No ACL_REQUEST_HEADERS sent, ACL_ALLOW_HEADERS should be empty 43 | ''' 44 | resp = self.preflight('/test_allow_headers', origin='www.example.com') 45 | self.assertEqual(resp.headers.get(ACL_ALLOW_HEADERS), None) 46 | 47 | def test_allow_headers_with_request_headers(self): 48 | ''' 49 | If there is an Access-Control-Request-Method header in the request 50 | and Access-Control-Request-Method is allowed for cross origin 51 | requests and request method is OPTIONS, and every element in the 52 | Access-Control-Request-Headers is an allowed header, the 53 | Access-Control-Allow-Headers header should be echoed back. 54 | ''' 55 | resp = self.preflight('/test_allow_headers', 56 | origin='www.example.com', 57 | cors_request_headers=['X-Example-Header-A']) 58 | self.assertEqual(resp.headers.get(ACL_ALLOW_HEADERS), 59 | 'X-Example-Header-A') 60 | 61 | def test_allow_headers_with_request_headers_case_insensitive(self): 62 | ''' 63 | HTTP headers are case insensitive. We should respect that 64 | and match regardless of case, returning the casing sent by 65 | the client 66 | ''' 67 | resp = self.preflight('/test_allow_headers', 68 | origin='www.example.com', 69 | cors_request_headers=['X-Example-header-a']) 70 | self.assertEqual(resp.headers.get(ACL_ALLOW_HEADERS), 71 | 'X-Example-header-a') 72 | 73 | def test_allow_headers_with_unmatched_request_headers(self): 74 | ''' 75 | If every element in the Access-Control-Request-Headers is not an 76 | allowed header, then the matching headers should be returned. 77 | ''' 78 | resp = self.preflight('/test_allow_headers', 79 | origin='www.example.com', 80 | cors_request_headers=['X-Not-Found-Header']) 81 | self.assertEqual(resp.headers.get(ACL_ALLOW_HEADERS), None) 82 | 83 | resp = self.preflight('/test_allow_headers', 84 | origin='www.example.com', 85 | cors_request_headers=['X-Example-Header-A', 86 | 'X-Not-Found-Header']) 87 | self.assertEqual(resp.headers.get(ACL_ALLOW_HEADERS), 88 | 'X-Example-Header-A') 89 | 90 | def test_allow_headers_regex(self): 91 | ''' 92 | If every element in the Access-Control-Request-Headers is not an 93 | allowed header, then the matching headers should be returned. 94 | ''' 95 | resp = self.preflight('/test_allow_headers_regex', 96 | origin='www.example.com', 97 | cors_request_headers=['X-COMPANY-FOO']) 98 | self.assertEqual(resp.headers.get(ACL_ALLOW_HEADERS), 'X-COMPANY-FOO') 99 | 100 | resp = self.preflight('/test_allow_headers_regex', 101 | origin='www.example.com', 102 | cors_request_headers=['X-Not-Found-Header']) 103 | self.assertEqual(resp.headers.get(ACL_ALLOW_HEADERS), None) 104 | 105 | 106 | if __name__ == "__main__": 107 | unittest.main() 108 | -------------------------------------------------------------------------------- /examples/sanic_ext_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sanic-Cors example 3 | =================== 4 | This is a tiny Sanic Application demonstrating Sanic-CORS, making it simple 5 | to add cross origin support to your sanic app! 6 | 7 | :copyright: (c) 2022 by Ashley Sommer (based on flask-cors by Cory Dolphin). 8 | :license: MIT/X11, see LICENSE for more details. 9 | """ 10 | from sanic import Sanic 11 | from sanic.response import json, html, text 12 | from sanic.exceptions import ServerError 13 | import logging 14 | try: 15 | from sanic_cors import CORS # The typical way to import sanic-cors 16 | except ImportError: 17 | # Path hack allows examples to be run without installation. 18 | import os 19 | parentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 20 | os.sys.path.insert(0, parentdir) 21 | from sanic_cors import CORS 22 | 23 | from sanic_ext import Extend 24 | 25 | app = Sanic('SanicCorsAppBasedExample') 26 | CORS_OPTIONS = {"resources": r'/api/*', "origins": "*", "methods": ["GET", "POST", "HEAD", "OPTIONS"]} 27 | # Disable sanic-ext built-in CORS, and add the Sanic-CORS plugin 28 | Extend(app, extensions=[CORS], config={"CORS": False, "CORS_OPTIONS": CORS_OPTIONS}) 29 | 30 | @app.route("/") 31 | def hello_world(request): 32 | ''' 33 | Since the path '/' does not match the regular expression r'/api/*', 34 | this route does not have CORS headers set. 35 | ''' 36 | return html(''' 37 | 38 | 39 | 40 |

Hello CORS!

41 |

End to end editable example with jquery!

42 | JS Bin on jsbin.com 43 | 44 | 45 | 46 | ''') 47 | 48 | @app.route("/api/v1/users/", methods=['GET', 'OPTIONS']) 49 | def list_users(request): 50 | ''' 51 | Since the path matches the regular expression r'/api/*', this resource 52 | automatically has CORS headers set. The expected result is as follows: 53 | 54 | $ curl --include -X GET http://127.0.0.1:5000/api/v1/users/ \ 55 | --header Origin:www.examplesite.com 56 | HTTP/1.0 200 OK 57 | Access-Control-Allow-Headers: Content-Type 58 | Access-Control-Allow-Origin: * 59 | Content-Length: 21 60 | Content-Type: application/json 61 | Date: Sat, 09 Aug 2014 00:26:41 GMT 62 | Server: Werkzeug/0.9.4 Python/2.7.8 63 | 64 | { 65 | "success": true 66 | } 67 | 68 | ''' 69 | return json({"user": "joe"}) 70 | 71 | 72 | @app.route("/api/v1/users/create", methods=['POST', 'OPTIONS']) 73 | def create_user(request): 74 | ''' 75 | Since the path matches the regular expression r'/api/*', this resource 76 | automatically has CORS headers set. 77 | 78 | Browsers will first make a preflight request to verify that the resource 79 | allows cross-origin POSTs with a JSON Content-Type, which can be simulated 80 | as: 81 | $ curl --include -X OPTIONS http://127.0.0.1:5000/api/v1/users/create \ 82 | --header Access-Control-Request-Method:POST \ 83 | --header Access-Control-Request-Headers:Content-Type \ 84 | --header Origin:www.examplesite.com 85 | >> HTTP/1.0 200 OK 86 | Content-Type: text/html; charset=utf-8 87 | Allow: POST, OPTIONS 88 | Access-Control-Allow-Origin: * 89 | Access-Control-Allow-Headers: Content-Type 90 | Access-Control-Allow-Methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT 91 | Content-Length: 0 92 | Server: Werkzeug/0.9.6 Python/2.7.9 93 | Date: Sat, 31 Jan 2015 22:25:22 GMT 94 | 95 | 96 | $ curl --include -X POST http://127.0.0.1:5000/api/v1/users/create \ 97 | --header Content-Type:application/json \ 98 | --header Origin:www.examplesite.com 99 | 100 | 101 | >> HTTP/1.0 200 OK 102 | Content-Type: application/json 103 | Content-Length: 21 104 | Access-Control-Allow-Origin: * 105 | Server: Werkzeug/0.9.6 Python/2.7.9 106 | Date: Sat, 31 Jan 2015 22:25:04 GMT 107 | 108 | { 109 | "success": true 110 | } 111 | 112 | ''' 113 | return json({"success": True}) 114 | 115 | @app.route("/api/exception") 116 | def get_exception(request): 117 | ''' 118 | Since the path matches the regular expression r'/api/*', this resource 119 | automatically has CORS headers set. 120 | 121 | Browsers will first make a preflight request to verify that the resource 122 | allows cross-origin POSTs with a JSON Content-Type, which can be simulated 123 | as: 124 | $ curl --include -X OPTIONS http://127.0.0.1:5000/exception \ 125 | --header Access-Control-Request-Method:POST \ 126 | --header Access-Control-Request-Headers:Content-Type \ 127 | --header Origin:www.examplesite.com 128 | >> HTTP/1.0 200 OK 129 | Content-Type: text/html; charset=utf-8 130 | Allow: POST, OPTIONS 131 | Access-Control-Allow-Origin: * 132 | Access-Control-Allow-Headers: Content-Type 133 | Access-Control-Allow-Methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT 134 | Content-Length: 0 135 | Server: Werkzeug/0.9.6 Python/2.7.9 136 | Date: Sat, 31 Jan 2015 22:25:22 GMT 137 | ''' 138 | raise Exception("example") 139 | 140 | @app.exception(ServerError) 141 | def server_error(request, e): 142 | logging.exception('An error occurred during a request. %s', e) 143 | return text("An internal error occured", status=500) 144 | 145 | 146 | if __name__ == "__main__": 147 | app.run(port=5000, debug=True, auto_reload=False) 148 | -------------------------------------------------------------------------------- /examples/app_based_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sanic-Cors example 3 | =================== 4 | This is a tiny Sanic Application demonstrating Sanic-CORS, making it simple 5 | to add cross origin support to your sanic app! 6 | 7 | :copyright: (c) 2022 by Ashley Sommer (based on flask-cors by Cory Dolphin). 8 | :license: MIT/X11, see LICENSE for more details. 9 | """ 10 | from sanic import Sanic 11 | from sanic.response import json, html, text 12 | from sanic.exceptions import ServerError 13 | import logging 14 | try: 15 | from sanic_cors import CORS # The typical way to import sanic-cors 16 | except ImportError: 17 | # Path hack allows examples to be run without installation. 18 | import os 19 | parentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 20 | os.sys.path.insert(0, parentdir) 21 | from sanic_cors import CORS 22 | 23 | 24 | app = Sanic('SanicCorsAppBasedExample') 25 | 26 | # One of the simplest configurations. Exposes all resources matching /api/* to 27 | # CORS and allows the Content-Type header, which is necessary to POST JSON 28 | # cross origin. 29 | CORS(app, resources=r'/api/*', origins="*", methods=["GET", "POST", "HEAD", "OPTIONS"]) 30 | 31 | 32 | @app.route("/") 33 | def hello_world(request): 34 | ''' 35 | Since the path '/' does not match the regular expression r'/api/*', 36 | this route does not have CORS headers set. 37 | ''' 38 | return html(''' 39 | 40 | 41 | 42 |

Hello CORS!

43 |

End to end editable example with jquery!

44 | JS Bin on jsbin.com 45 | 46 | 47 | 48 | ''') 49 | 50 | @app.route("/api/v1/users/", methods=['GET']) 51 | def list_users(request): 52 | ''' 53 | Since the path matches the regular expression r'/api/*', this resource 54 | automatically has CORS headers set. The expected result is as follows: 55 | 56 | $ curl --include -X GET http://127.0.0.1:5000/api/v1/users/ \ 57 | --header Origin:www.examplesite.com 58 | HTTP/1.0 200 OK 59 | Access-Control-Allow-Headers: Content-Type 60 | Access-Control-Allow-Origin: * 61 | Content-Length: 21 62 | Content-Type: application/json 63 | Date: Sat, 09 Aug 2014 00:26:41 GMT 64 | Server: Werkzeug/0.9.4 Python/2.7.8 65 | 66 | { 67 | "success": true 68 | } 69 | 70 | ''' 71 | return json({"user": "joe"}) 72 | 73 | 74 | @app.route("/api/v1/users/create", methods=['POST', 'OPTIONS']) 75 | def create_user(request): 76 | ''' 77 | Since the path matches the regular expression r'/api/*', this resource 78 | automatically has CORS headers set. 79 | 80 | Browsers will first make a preflight request to verify that the resource 81 | allows cross-origin POSTs with a JSON Content-Type, which can be simulated 82 | as: 83 | $ curl --include -X OPTIONS http://127.0.0.1:5000/api/v1/users/create \ 84 | --header Access-Control-Request-Method:POST \ 85 | --header Access-Control-Request-Headers:Content-Type \ 86 | --header Origin:www.examplesite.com 87 | >> HTTP/1.0 200 OK 88 | Content-Type: text/html; charset=utf-8 89 | Allow: POST, OPTIONS 90 | Access-Control-Allow-Origin: * 91 | Access-Control-Allow-Headers: Content-Type 92 | Access-Control-Allow-Methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT 93 | Content-Length: 0 94 | Server: Werkzeug/0.9.6 Python/2.7.9 95 | Date: Sat, 31 Jan 2015 22:25:22 GMT 96 | 97 | 98 | $ curl --include -X POST http://127.0.0.1:5000/api/v1/users/create \ 99 | --header Content-Type:application/json \ 100 | --header Origin:www.examplesite.com 101 | 102 | 103 | >> HTTP/1.0 200 OK 104 | Content-Type: application/json 105 | Content-Length: 21 106 | Access-Control-Allow-Origin: * 107 | Server: Werkzeug/0.9.6 Python/2.7.9 108 | Date: Sat, 31 Jan 2015 22:25:04 GMT 109 | 110 | { 111 | "success": true 112 | } 113 | 114 | ''' 115 | return json({"success": True}) 116 | 117 | @app.route("/api/exception") 118 | def get_exception(request): 119 | ''' 120 | Since the path matches the regular expression r'/api/*', this resource 121 | automatically has CORS headers set. 122 | 123 | Browsers will first make a preflight request to verify that the resource 124 | allows cross-origin POSTs with a JSON Content-Type, which can be simulated 125 | as: 126 | $ curl --include -X OPTIONS http://127.0.0.1:5000/exception \ 127 | --header Access-Control-Request-Method:POST \ 128 | --header Access-Control-Request-Headers:Content-Type \ 129 | --header Origin:www.examplesite.com 130 | >> HTTP/1.0 200 OK 131 | Content-Type: text/html; charset=utf-8 132 | Allow: POST, OPTIONS 133 | Access-Control-Allow-Origin: * 134 | Access-Control-Allow-Headers: Content-Type 135 | Access-Control-Allow-Methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT 136 | Content-Length: 0 137 | Server: Werkzeug/0.9.6 Python/2.7.9 138 | Date: Sat, 31 Jan 2015 22:25:22 GMT 139 | ''' 140 | raise Exception("example") 141 | 142 | @app.middleware("response") 143 | def test_it(request, response): 144 | print(request) 145 | 146 | @app.exception(ServerError) 147 | def server_error(request, e): 148 | logging.exception('An error occurred during a request. %s', e) 149 | return text("An internal error occured", status=500) 150 | 151 | 152 | if __name__ == "__main__": 153 | app.run(port=5000, debug=True, auto_reload=False) 154 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Sanic-Cors.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Sanic-Cors.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Sanic-Cors" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Sanic-Cors" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Sanic-CORS 2 | ========== 3 | 4 | |Build Status| |Latest Version| |Supported Python versions| 5 | |License| 6 | 7 | A Sanic extension for handling Cross Origin Resource Sharing (CORS), 8 | making cross-origin AJAX possible. Based on 9 | `flask-cors `__ by Cory Dolphin. 10 | 11 | This package has a simple philosophy, when you want to enable CORS, you 12 | wish to enable it for all use cases on a domain. This means no mucking 13 | around with different allowed headers, methods, etc. By default, 14 | submission of cookies across domains is disabled due to the security 15 | implications, please see the documentation for how to enable 16 | credential'ed requests, and please make sure you add some sort of 17 | `CSRF `__ 18 | protection before doing so! 19 | 20 | **Sept 2022 Notice:** 21 | If you are having unexpected results in Sanic v22.9+, upgrade to Sanic-CORS v2.2.0 22 | 23 | **December 2021 Notice:** 24 | If you need compatibility with Sanic v21.12+, upgrade to Sanic-CORS v2.0 25 | 26 | **Sept 2021 Notice:** 27 | Please upgrade to Sanic-CORS v1.0.1 if you need compatibility with Sanic v21.9,<21.12 28 | 29 | Installation 30 | ------------ 31 | 32 | Install the extension with using pip, or easy\_install. 33 | 34 | .. code:: bash 35 | 36 | $ pip install -U sanic-cors 37 | 38 | Usage 39 | ----- 40 | 41 | This package exposes a Sanic extension which by default enables CORS support on 42 | all routes, for all origins and methods. It allows parameterization of all 43 | CORS headers on a per-resource level. The package also contains a decorator, 44 | for those who prefer this approach. 45 | 46 | Simple Usage 47 | ~~~~~~~~~~~~ 48 | 49 | In the simplest case, initialize the Sanic-Cors extension with default 50 | arguments in order to allow CORS for all domains on all routes. 51 | 52 | .. code:: python 53 | 54 | from sanic import Sanic 55 | from sanic.response import text 56 | from sanic_cors import CORS, cross_origin 57 | 58 | app = Sanic(__name__) 59 | CORS(app) 60 | 61 | @app.route("/", methods=['GET', 'OPTIONS']) 62 | def hello_world(request): 63 | return text("Hello, cross-origin-world!") 64 | 65 | Resource specific CORS 66 | ^^^^^^^^^^^^^^^^^^^^^^ 67 | 68 | Alternatively, you can specify CORS options on a resource and origin 69 | level of granularity by passing a dictionary as the `resources` option, 70 | mapping paths to a set of options. 71 | 72 | .. code:: python 73 | 74 | app = Sanic(__name__) 75 | cors = CORS(app, resources={r"/api/*": {"origins": "*"}}) 76 | 77 | @app.route("/api/v1/users", methods=['GET', 'OPTIONS']) 78 | def list_users(request): 79 | return text("user example") 80 | 81 | Route specific CORS via decorator 82 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 83 | 84 | This extension also exposes a simple decorator to decorate sanic routes 85 | with. Simply add ``@cross_origin(app)`` below a call to Sanic's 86 | ``@app.route(..)`` to allow CORS on a given route. 87 | 88 | .. code:: python 89 | 90 | @app.route("/", methods=['GET', 'OPTIONS']) 91 | @cross_origin(app) 92 | def hello_world(request): 93 | return text("Hello, cross-origin-world!") 94 | 95 | Sanic-Ext Usage 96 | ~~~~~~~~~~~~~~~ 97 | 98 | Sanic-CORS can use Sanic-Ext to load the plugin for you. 99 | (But you need to make sure to disable the built-in sanic-ext CORS support too) 100 | 101 | .. code:: python 102 | 103 | from sanic import Sanic 104 | from sanic.response import text 105 | from sanic_ext import Extend 106 | from sanic_cors.extension import CORS 107 | app = Sanic(__name__) 108 | CORS_OPTIONS = {"resources": r'/*', "origins": "*", "methods": ["GET", "POST", "HEAD", "OPTIONS"]} 109 | # Disable sanic-ext built-in CORS, and add the Sanic-CORS plugin 110 | Extend(app, extensions=[CORS], config={"CORS": False, "CORS_OPTIONS": CORS_OPTIONS}) 111 | 112 | @app.route("/", methods=['GET', 'OPTIONS']) 113 | def hello_world(request): 114 | return text("Hello, cross-origin-world!") 115 | 116 | 117 | Documentation 118 | ------------- 119 | 120 | For a full list of options, please see the flask-cors 121 | `documentation `__. 122 | 123 | Preflight Requests 124 | ------------------ 125 | CORS requests have to send `pre-flight requests `_ 126 | via the options method, Sanic by default only allows the ``GET`` method, in order to 127 | service your CORS requests you must specify ``OPTIONS`` in the methods argument to 128 | your routes decorator. 129 | 130 | Sanic-CORS includes an ``automatic_options`` configuration parameter to 131 | allow the plugin handle the ``OPTIONS`` response automatically for you. This is enabled by default, but you 132 | can turn it off if you wish to do your own ``OPTIONS`` response. 133 | 134 | .. code:: python 135 | 136 | CORS(app, automatic_options=True) 137 | 138 | @app.delete('/api/auth') 139 | @auth.login_required 140 | async def auth_logout(request): 141 | auth.logout_user(request) 142 | return json(None, status=OK) 143 | 144 | or with the app config key: 145 | 146 | .. code:: python 147 | 148 | app = Sanic(__name__) 149 | app.config['CORS_AUTOMATIC_OPTIONS'] = True 150 | 151 | CORS(app) 152 | 153 | @app.delete('/api/auth') 154 | @auth.login_required 155 | async def auth_logout(request): 156 | auth.logout_user(request) 157 | return json(None, status=OK) 158 | 159 | or directly on the route with the ``cross_origin`` decorator: 160 | 161 | .. code:: python 162 | 163 | @app.route('/api/auth', methods={'DELETE','OPTIONS'}) 164 | @auth.login_required 165 | @cross_origin(app, automatic_options=True) 166 | async def auth_logout(request): 167 | auth.logout_user(request) 168 | return json(None, status=OK) 169 | 170 | Note: For the third example, you must use ``@route()``, rather than 171 | ``@delete()`` because you need to enable both ``DELETE`` and ``OPTIONS`` to 172 | work on that route, even though the decorator is handling the ``OPTIONS`` 173 | response. 174 | 175 | Tests 176 | ----- 177 | 178 | A simple set of tests is included in ``test/``. To run, install nose, 179 | and simply invoke ``nosetests`` or ``python setup.py test`` to exercise 180 | the tests. 181 | 182 | Contributing 183 | ------------ 184 | 185 | Questions, comments or improvements? Please create an issue on 186 | `Github `__. I do my best to 187 | include every contribution proposed in any way that I can. 188 | 189 | Credits 190 | ------- 191 | 192 | This Sanic extension is based upon the `Decorator for the HTTP Access 193 | Control `__ written by Armin 194 | Ronacher. 195 | 196 | .. |Build Status| image:: https://api.travis-ci.org/ashleysommer/sanic-cors.svg?branch=master 197 | :target: https://travis-ci.org/ashleysommer/sanic-cors 198 | .. |Latest Version| image:: https://img.shields.io/pypi/v/Sanic-Cors.svg 199 | :target: https://pypi.python.org/pypi/Sanic-Cors/ 200 | .. |Supported Python versions| image:: https://img.shields.io/pypi/pyversions/Sanic-Cors.svg 201 | :target: https://img.shields.io/pypi/pyversions/Sanic-Cors.svg 202 | .. |License| image:: http://img.shields.io/:license-mit-blue.svg 203 | :target: https://pypi.python.org/pypi/Sanic-Cors/ 204 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Sanic-CORS documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Aug 6 00:14:19 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | sys.path.insert(0, os.path.abspath('..')) 22 | import sanic_cors 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | #needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ['_templates'] 36 | 37 | # The suffix of source filenames. 38 | source_suffix = '.rst' 39 | 40 | # The encoding of source files. 41 | #source_encoding = 'utf-8-sig' 42 | 43 | # The master toctree document. 44 | master_doc = 'index' 45 | 46 | # General information about the project. 47 | project = u'Sanic-Cors' 48 | copyright = u'2013, Cory Dolphin' 49 | 50 | # The version info for the project you're documenting, acts as replacement for 51 | # |version| and |release|, also used in various other places throughout the 52 | # built documents. 53 | 54 | version = sanic_cors.__version__ 55 | release = version 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | #language = None 60 | 61 | # There are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | #today = '' 64 | # Else, today_fmt is used as the format for a strftime call. 65 | #today_fmt = '%B %d, %Y' 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | exclude_patterns = ['_build'] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all 72 | # documents. 73 | #default_role = None 74 | 75 | # If true, '()' will be appended to :func: etc. cross-reference text. 76 | #add_function_parentheses = True 77 | 78 | # If true, the current module name will be prepended to all description 79 | # unit titles (such as .. function::). 80 | #add_module_names = True 81 | 82 | # If true, sectionauthor and moduleauthor directives will be shown in the 83 | # output. They are ignored by default. 84 | #show_authors = False 85 | 86 | # The name of the Pygments (syntax highlighting) style to use. 87 | pygments_style = 'sphinx' 88 | 89 | # A list of ignored prefixes for module index sorting. 90 | #modindex_common_prefix = [] 91 | 92 | # If true, keep warnings as "system message" paragraphs in the built documents. 93 | #keep_warnings = False 94 | 95 | 96 | # -- Options for HTML output ---------------------------------------------- 97 | 98 | # The theme to use for HTML and HTML Help pages. See the documentation for 99 | # a list of builtin themes. 100 | html_theme = 'sphinx_rtd_theme' 101 | 102 | # Theme options are theme-specific and customize the look and feel of a theme 103 | # further. For a list of options available for each theme, see the 104 | # documentation. 105 | #html_theme_options = {} 106 | 107 | # Add any paths that contain custom themes here, relative to this directory. 108 | #html_theme_path = [] 109 | 110 | # The name for this set of Sphinx documents. If None, it defaults to 111 | # " v documentation". 112 | #html_title = None 113 | 114 | # A shorter title for the navigation bar. Default is the same as html_title. 115 | #html_short_title = None 116 | 117 | # The name of an image file (relative to this directory) to place at the top 118 | # of the sidebar. 119 | #html_logo = None 120 | 121 | # The name of an image file (within the static path) to use as favicon of the 122 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 123 | # pixels large. 124 | #html_favicon = None 125 | 126 | # Add any paths that contain custom static files (such as style sheets) here, 127 | # relative to this directory. They are copied after the builtin static files, 128 | # so a file named "default.css" will overwrite the builtin "default.css". 129 | html_static_path = ['_static'] 130 | 131 | # Add any extra paths that contain custom files (such as robots.txt or 132 | # .htaccess) here, relative to this directory. These files are copied 133 | # directly to the root of the documentation. 134 | #html_extra_path = [] 135 | 136 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 137 | # using the given strftime format. 138 | #html_last_updated_fmt = '%b %d, %Y' 139 | 140 | # If true, SmartyPants will be used to convert quotes and dashes to 141 | # typographically correct entities. 142 | #html_use_smartypants = True 143 | 144 | # Custom sidebar templates, maps document names to template names. 145 | #html_sidebars = {} 146 | 147 | # Additional templates that should be rendered to pages, maps page names to 148 | # template names. 149 | #html_additional_pages = {} 150 | 151 | # If false, no module index is generated. 152 | #html_domain_indices = True 153 | 154 | # If false, no index is generated. 155 | #html_use_index = True 156 | 157 | # If true, the index is split into individual pages for each letter. 158 | #html_split_index = False 159 | 160 | # If true, links to the reST sources are added to the pages. 161 | #html_show_sourcelink = True 162 | 163 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 164 | #html_show_sphinx = True 165 | 166 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 167 | #html_show_copyright = True 168 | 169 | # If true, an OpenSearch description file will be output, and all pages will 170 | # contain a tag referring to it. The value of this option must be the 171 | # base URL from which the finished HTML is served. 172 | #html_use_opensearch = '' 173 | 174 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 175 | #html_file_suffix = None 176 | 177 | # Output file base name for HTML help builder. 178 | htmlhelp_basename = 'Sanic-Corsdoc' 179 | 180 | 181 | # -- Options for LaTeX output --------------------------------------------- 182 | 183 | latex_elements = { 184 | # The paper size ('letterpaper' or 'a4paper'). 185 | #'papersize': 'letterpaper', 186 | 187 | # The font size ('10pt', '11pt' or '12pt'). 188 | #'pointsize': '10pt', 189 | 190 | # Additional stuff for the LaTeX preamble. 191 | #'preamble': '', 192 | } 193 | 194 | # Grouping the document tree into LaTeX files. List of tuples 195 | # (source start file, target name, title, 196 | # author, documentclass [howto, manual, or own class]). 197 | latex_documents = [ 198 | ('index', 'Sanic-Cors.tex', u'Sanic-Cors Documentation', 199 | u'Cory Dolphin', 'manual'), 200 | ] 201 | 202 | # The name of an image file (relative to this directory) to place at the top of 203 | # the title page. 204 | #latex_logo = None 205 | 206 | # For "manual" documents, if this is true, then toplevel headings are parts, 207 | # not chapters. 208 | #latex_use_parts = False 209 | 210 | # If true, show page references after internal links. 211 | #latex_show_pagerefs = False 212 | 213 | # If true, show URL addresses after external links. 214 | #latex_show_urls = False 215 | 216 | # Documents to append as an appendix to all manuals. 217 | #latex_appendices = [] 218 | 219 | # If false, no module index is generated. 220 | #latex_domain_indices = True 221 | 222 | 223 | # -- Options for manual page output --------------------------------------- 224 | 225 | # One entry per manual page. List of tuples 226 | # (source start file, name, description, authors, manual section). 227 | man_pages = [ 228 | ('index', 'sanic-cors', u'Sanic-Cors Documentation', 229 | [u'Cory Dolphin'], 1) 230 | ] 231 | 232 | # If true, show URL addresses after external links. 233 | #man_show_urls = False 234 | 235 | 236 | # -- Options for Texinfo output ------------------------------------------- 237 | 238 | # Grouping the document tree into Texinfo files. List of tuples 239 | # (source start file, target name, title, author, 240 | # dir menu entry, description, category) 241 | texinfo_documents = [ 242 | ('index', 'Sanic-Cors', u'Sanic-Cors Documentation', 243 | u'Cory Dolphin', 'Sanic-Cors', 'One line description of project.', 244 | 'Miscellaneous'), 245 | ] 246 | 247 | # Documents to append as an appendix to all manuals. 248 | #texinfo_appendices = [] 249 | 250 | # If false, no module index is generated. 251 | #texinfo_domain_indices = True 252 | 253 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 254 | #texinfo_show_urls = 'footnote' 255 | 256 | 257 | # Example configuration for intersphinx: refer to the Python standard library. 258 | intersphinx_mapping = {'http://docs.python.org/': None} 259 | -------------------------------------------------------------------------------- /tests/decorator/test_origins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | 12 | from ..base_test import SanicCorsTestCase 13 | from sanic import Sanic 14 | from sanic.response import text 15 | import re 16 | from sanic_cors import * 17 | from sanic_cors.core import * 18 | 19 | letters = 'abcdefghijklmnopqrstuvwxyz' # string.letters is not PY3 compatible 20 | 21 | class OriginsTestCase(SanicCorsTestCase): 22 | def setUp(self): 23 | self.app = Sanic(__name__.replace(".","-")) 24 | 25 | @self.app.route('/', methods=['GET', 'HEAD', 'OPTIONS']) 26 | @cross_origin(self.app) 27 | def wildcard(request): 28 | return text('Welcome!') 29 | 30 | @self.app.route('/test_always_send') 31 | @cross_origin(self.app, always_send=True) 32 | def test_always_send(request): 33 | return text('Welcome!') 34 | 35 | @self.app.route('/test_always_send_no_wildcard') 36 | @cross_origin(self.app, always_send=True, send_wildcard=False) 37 | def test_always_send_no_wildcard(request): 38 | return text('Welcome!') 39 | 40 | @self.app.route('/test_send_wildcard_with_origin', methods=['GET', 'HEAD', 'OPTIONS']) 41 | @cross_origin(self.app, send_wildcard=True) 42 | def test_send_wildcard_with_origin(request): 43 | return text('Welcome!') 44 | 45 | @self.app.route('/test_list') 46 | @cross_origin(self.app, origins=["http://foo.com", "http://bar.com"]) 47 | def test_list(request): 48 | return text('Welcome!') 49 | 50 | @self.app.route('/test_string') 51 | @cross_origin(self.app, origins="http://foo.com") 52 | def test_string(request): 53 | return text('Welcome!') 54 | 55 | @self.app.route('/test_set') 56 | @cross_origin(self.app, origins=set(["http://foo.com", "http://bar.com"])) 57 | def test_set(request): 58 | return text('Welcome!') 59 | 60 | @self.app.route('/test_subdomain_regex', methods=['GET', 'HEAD', 'OPTIONS']) 61 | @cross_origin(self.app, origins=r"http?://\w*\.?example\.com:?\d*/?.*") 62 | def test_subdomain_regex(request): 63 | return text('') 64 | 65 | @self.app.route('/test_compiled_subdomain_regex', methods=['GET', 'HEAD', 'OPTIONS']) 66 | @cross_origin(self.app, origins=re.compile(r"http?://\w*\.?example\.com:?\d*/?.*")) 67 | def test_compiled_subdomain_regex(request): 68 | return text('') 69 | 70 | @self.app.route('/test_regex_list', methods=['GET', 'HEAD', 'OPTIONS']) 71 | @cross_origin(self.app, origins=[r".*.example.com", r".*.otherexample.com"]) 72 | def test_regex_list(request): 73 | return text('') 74 | 75 | @self.app.route('/test_regex_mixed_list', methods=['GET', 'HEAD', 'OPTIONS']) 76 | @cross_origin(self.app, origins=["http://example.com", r".*.otherexample.com"]) 77 | def test_regex_mixed_list(request): 78 | return text('') 79 | 80 | @self.app.route('/test_multiple_protocols') 81 | @cross_origin(self.app, origins="https?://example.com") 82 | def test_multiple_protocols(request): 83 | return text('') 84 | 85 | def test_defaults_no_origin(self): 86 | ''' If there is no Origin header in the request, the 87 | Access-Control-Allow-Origin header should be '*' by default. 88 | ''' 89 | for resp in self.iter_responses('/'): 90 | self.assertEqual(resp.headers.get(ACL_ORIGIN), '*') 91 | 92 | def test_defaults_with_origin(self): 93 | ''' If there is an Origin header in the request, the 94 | Access-Control-Allow-Origin header should be included. 95 | ''' 96 | for resp in self.iter_responses('/', origin='http://example.com'): 97 | self.assertEqual(resp.status, 200) 98 | self.assertEqual(resp.headers.get(ACL_ORIGIN), 'http://example.com') 99 | 100 | def test_always_send_no_wildcard(self): 101 | ''' 102 | If send_wildcard=False, but the there is '*' in the 103 | allowed origins, we should send it anyways. 104 | ''' 105 | for resp in self.iter_responses('/'): 106 | self.assertEqual(resp.status, 200) 107 | self.assertEqual(resp.headers.get(ACL_ORIGIN), '*') 108 | 109 | def test_always_send_no_wildcard_origins(self): 110 | for resp in self.iter_responses('/'): 111 | self.assertEqual(resp.status, 200) 112 | self.assertEqual(resp.headers.get(ACL_ORIGIN), '*') 113 | 114 | 115 | def test_send_wildcard_with_origin(self): 116 | ''' If there is an Origin header in the request, the 117 | Access-Control-Allow-Origin header should be included. 118 | ''' 119 | for resp in self.iter_responses('/test_send_wildcard_with_origin', origin='http://example.com'): 120 | self.assertEqual(resp.status, 200) 121 | self.assertEqual(resp.headers.get(ACL_ORIGIN), '*') 122 | 123 | def test_list_serialized(self): 124 | ''' If there is an Origin header in the request, the 125 | Access-Control-Allow-Origin header should be echoed. 126 | ''' 127 | resp = self.get('/test_list', origin='http://bar.com') 128 | self.assertEqual(resp.headers.get(ACL_ORIGIN), 'http://bar.com') 129 | 130 | def test_string_serialized(self): 131 | ''' If there is an Origin header in the request, 132 | the Access-Control-Allow-Origin header should be echoed back. 133 | ''' 134 | resp = self.get('/test_string', origin='http://foo.com') 135 | self.assertEqual(resp.headers.get(ACL_ORIGIN), 'http://foo.com') 136 | 137 | def test_set_serialized(self): 138 | ''' If there is an Origin header in the request, 139 | the Access-Control-Allow-Origin header should be echoed back. 140 | ''' 141 | resp = self.get('/test_set', origin='http://bar.com') 142 | 143 | allowed = resp.headers.get(ACL_ORIGIN) 144 | # Order is not garaunteed 145 | self.assertEqual(allowed, 'http://bar.com') 146 | 147 | def test_not_matching_origins(self): 148 | for resp in self.iter_responses('/test_list', origin="http://bazz.com"): 149 | self.assertFalse(ACL_ORIGIN in resp.headers) 150 | 151 | def test_subdomain_regex(self): 152 | for sub in letters: 153 | domain = "http://%s.example.com" % sub 154 | for resp in self.iter_responses('/test_subdomain_regex', 155 | headers={'origin': domain}): 156 | self.assertEqual(domain, resp.headers.get(ACL_ORIGIN)) 157 | 158 | def test_compiled_subdomain_regex(self): 159 | for sub in letters: 160 | domain = "http://%s.example.com" % sub 161 | for resp in self.iter_responses('/test_compiled_subdomain_regex', 162 | headers={'origin': domain}): 163 | self.assertEqual(domain, resp.headers.get(ACL_ORIGIN)) 164 | 165 | def test_regex_list(self): 166 | for parent in 'example.com', 'otherexample.com': 167 | for sub in letters: 168 | domain = "http://%s.%s.com" % (sub, parent) 169 | for resp in self.iter_responses('/test_regex_list', 170 | headers={'origin': domain}): 171 | self.assertEqual(domain, resp.headers.get(ACL_ORIGIN)) 172 | 173 | def test_regex_mixed_list(self): 174 | ''' 175 | Tests the corner case occurs when the send_always setting is True 176 | and no Origin header in the request, it is not possible to match 177 | the regular expression(s) to determine the correct 178 | Access-Control-Allow-Origin header to be returned. Instead, the 179 | list of origins is serialized, and any strings which seem like 180 | regular expressions (e.g. are not a '*' and contain either '*' 181 | or '?') will be skipped. 182 | 183 | Thus, the list of returned Access-Control-Allow-Origin header 184 | is garaunteed to be 'null', the origin or "*", as per the w3 185 | http://www.w3.org/TR/cors/#access-control-allow-origin-response-header 186 | 187 | ''' 188 | for sub in letters: 189 | domain = "http://%s.otherexample.com" % sub 190 | for resp in self.iter_responses('/test_regex_mixed_list', 191 | origin=domain): 192 | self.assertEqual(domain, resp.headers.get(ACL_ORIGIN)) 193 | 194 | self.assertEqual("http://example.com", 195 | self.get('/test_regex_mixed_list', origin='http://example.com').headers.get(ACL_ORIGIN)) 196 | 197 | def test_multiple_protocols(self): 198 | import logging 199 | logging.getLogger('sanic_cors').level = logging.DEBUG 200 | resp = self.get('/test_multiple_protocols', origin='https://example.com') 201 | self.assertEqual('https://example.com', resp.headers.get(ACL_ORIGIN)) 202 | 203 | 204 | if __name__ == "__main__": 205 | unittest.main() 206 | -------------------------------------------------------------------------------- /tests/decorator/test_exception_interception.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | from ..base_test import SanicCorsTestCase 12 | from sanic import Sanic 13 | from sanic_cors import * 14 | from sanic_cors.core import * 15 | from sanic.exceptions import NotFound, ServerError 16 | from sanic.response import text 17 | 18 | def add_routes(app): 19 | #@app.route('/test_no_acl_abort_404') 20 | #@app.route('/test_acl_abort_404') 21 | def test_acl_abort_404(request): 22 | raise NotFound("") 23 | app.route('/test_no_acl_abort_404')(test_acl_abort_404) 24 | app.route('/test_acl_abort_404')(test_acl_abort_404) 25 | 26 | #@app.route('/test_no_acl_async_abort_404') 27 | #@app.route('/test_acl_async_abort_404') 28 | async def test_acl_async_abort_404(request): 29 | raise NotFound("") 30 | app.route('/test_no_acl_async_abort_404')(test_acl_async_abort_404) 31 | app.route('/test_acl_async_abort_404')(test_acl_async_abort_404) 32 | 33 | #@app.route('/test_no_acl_abort_500') 34 | #@app.route('/test_acl_abort_500') 35 | def test_acl_abort_500(request): 36 | raise ServerError("") 37 | app.route('/test_no_acl_abort_500')(test_acl_abort_500) 38 | app.route('/test_acl_abort_500')(test_acl_abort_500) 39 | 40 | @app.route('/test_acl_uncaught_exception_500') 41 | def test_acl_uncaught_exception_500(request): 42 | raise Exception("This could've been any exception") 43 | 44 | @app.route('/test_no_acl_uncaught_exception_500') 45 | def test_no_acl_uncaught_exception_500(request): 46 | raise Exception("This could've been any exception") 47 | 48 | 49 | class ExceptionInterceptionDefaultTestCase(SanicCorsTestCase): 50 | def setUp(self): 51 | self.app = Sanic(__name__.replace(".","-")) 52 | CORS(self.app, resources={ 53 | r'/test_acl*': {}, 54 | }) 55 | add_routes(self.app) 56 | 57 | def test_acl_abort_404(self): 58 | ''' 59 | HTTP Responses generated by calling abort are handled identically 60 | to normal responses, and should be wrapped by CORS headers if the 61 | path matches. This path matches. 62 | ''' 63 | resp = self.get('/test_acl_abort_404', origin='www.example.com') 64 | self.assertEqual(resp.status, 404) 65 | self.assertTrue(ACL_ORIGIN in resp.headers) 66 | 67 | def test_acl_async_abort_404(self): 68 | ''' 69 | HTTP Responses generated by calling abort are handled identically 70 | to normal responses, and should be wrapped by CORS headers if the 71 | path matches. This path matches. 72 | ''' 73 | resp = self.get('/test_acl_async_abort_404', origin='www.example.com') 74 | self.assertEqual(resp.status, 404) 75 | self.assertTrue(ACL_ORIGIN in resp.headers) 76 | 77 | def test_no_acl_abort_404(self): 78 | ''' 79 | HTTP Responses generated by calling abort are handled identically 80 | to normal responses, and should be wrapped by CORS headers if the 81 | path matches. This path does not match. 82 | ''' 83 | resp = self.get('/test_no_acl_abort_404', origin='www.example.com') 84 | self.assertEqual(resp.status, 404) 85 | self.assertFalse(ACL_ORIGIN in resp.headers) 86 | 87 | def test_no_acl_async_abort_404(self): 88 | ''' 89 | HTTP Responses generated by calling abort are handled identically 90 | to normal responses, and should be wrapped by CORS headers if the 91 | path matches. This path does not match. 92 | ''' 93 | resp = self.get('/test_no_acl_async_abort_404', origin='www.example.com') 94 | self.assertEqual(resp.status, 404) 95 | self.assertFalse(ACL_ORIGIN in resp.headers) 96 | 97 | def test_acl_abort_500(self): 98 | ''' 99 | HTTP Responses generated by calling abort are handled identically 100 | to normal responses, and should be wrapped by CORS headers if the 101 | path matches. This path matches 102 | ''' 103 | resp = self.get('/test_acl_abort_500', origin='www.example.com') 104 | self.assertEqual(resp.status, 500) 105 | self.assertTrue(ACL_ORIGIN in resp.headers) 106 | 107 | def test_no_acl_abort_500(self): 108 | ''' 109 | HTTP Responses generated by calling abort are handled identically 110 | to normal responses, and should be wrapped by CORS headers if the 111 | path matches. This path matches 112 | ''' 113 | resp = self.get('/test_no_acl_abort_500', origin='www.example.com') 114 | self.assertEqual(resp.status, 500) 115 | self.assertFalse(ACL_ORIGIN in resp.headers) 116 | 117 | def test_acl_uncaught_exception_500(self): 118 | ''' 119 | Uncaught exceptions will trigger Sanic's internal exception 120 | handler, and should have ACL headers only if intercept_exceptions 121 | is set to True and if the request URL matches the resources pattern 122 | 123 | This url matches. 124 | ''' 125 | 126 | resp = self.get('/test_acl_uncaught_exception_500', origin='www.example.com') 127 | self.assertEqual(resp.status, 500) 128 | self.assertTrue(ACL_ORIGIN in resp.headers) 129 | 130 | def test_no_acl_uncaught_exception_500(self): 131 | ''' 132 | Uncaught exceptions will trigger Sanic's internal exception 133 | handler, and should have ACL headers only if intercept_exceptions 134 | is set to True and if the request URL matches the resources pattern. 135 | 136 | This url does not match. 137 | ''' 138 | 139 | resp = self.get('/test_no_acl_uncaught_exception_500', origin='www.example.com') 140 | self.assertEqual(resp.status, 500) 141 | self.assertFalse(ACL_ORIGIN in resp.headers) 142 | 143 | def test_acl_exception_with_error_handler(self): 144 | ''' 145 | If a 500 handler is setup by the user, responses should have 146 | CORS matching rules applied, regardless of whether or not 147 | intercept_exceptions is enabled. 148 | ''' 149 | return_string = "Simple error handler" 150 | 151 | @self.app.exception(NotFound, ServerError, Exception) 152 | def catch_all_handler(request, exception): 153 | ''' 154 | This error handler catches 404s and 500s and returns 155 | status 200 no matter what. It is not a good handler. 156 | ''' 157 | return text(return_string) 158 | 159 | acl_paths = [ 160 | '/test_acl_abort_404', 161 | '/test_acl_abort_500', 162 | '/test_acl_uncaught_exception_500' 163 | ] 164 | no_acl_paths = [ 165 | '/test_no_acl_abort_404', 166 | '/test_no_acl_abort_500', 167 | '/test_no_acl_uncaught_exception_500' 168 | ] 169 | 170 | def get_with_origins(path): 171 | response = self.get(path, origin='www.example.com') 172 | return response 173 | 174 | for resp in map(get_with_origins, acl_paths): 175 | self.assertEqual(resp.status, 200) 176 | self.assertTrue(ACL_ORIGIN in resp.headers) 177 | 178 | for resp in map(get_with_origins, no_acl_paths): 179 | self.assertEqual(resp.status, 200) 180 | self.assertFalse(ACL_ORIGIN in resp.headers) 181 | 182 | 183 | class NoExceptionInterceptionTestCase(ExceptionInterceptionDefaultTestCase): 184 | 185 | def setUp(self): 186 | self.app = Sanic(__name__.replace(".","-")) 187 | CORS(self.app, 188 | intercept_exceptions=False, 189 | resources={ 190 | r'/test_acl*': {}, 191 | }) 192 | add_routes(self.app) 193 | 194 | def test_acl_exception_with_error_handler(self): 195 | ''' 196 | If a 500 handler is setup by the user, responses should have 197 | CORS matching rules applied, regardless of whether or not 198 | intercept_exceptions is enbaled. 199 | ''' 200 | return_string = "Simple error handler" 201 | 202 | @self.app.exception(ServerError, NotFound, Exception) 203 | # Note, async error handlers don't work in Sanic yet. 204 | # async def catch_all_handler(request, exception): 205 | def catch_all_handler(request, exception): 206 | ''' 207 | This error handler catches 404s and 500s and returns 208 | status 200 no matter what. It is not a good handler. 209 | ''' 210 | return text(return_string, 200) 211 | 212 | acl_paths = [ 213 | '/test_acl_abort_404', 214 | '/test_acl_abort_500' 215 | ] 216 | no_acl_paths = [ 217 | '/test_no_acl_abort_404', 218 | '/test_no_acl_abort_500', 219 | '/test_no_acl_uncaught_exception_500', 220 | '/test_acl_uncaught_exception_500' 221 | ] 222 | def get_with_origins(path): 223 | return self.get(path, origin='www.example.com') 224 | 225 | for resp in map(get_with_origins, acl_paths): 226 | self.assertEqual(resp.status, 200) 227 | self.assertTrue(ACL_ORIGIN in resp.headers) 228 | 229 | for resp in map(get_with_origins, no_acl_paths): 230 | self.assertEqual(resp.status, 200) 231 | self.assertFalse(ACL_ORIGIN in resp.headers) 232 | 233 | def test_acl_uncaught_exception_500(self): 234 | ''' 235 | Uncaught exceptions will trigger Sanic's internal exception 236 | handler, and should have ACL headers only if intercept_exceptions 237 | is set to True. In this case it is not. 238 | ''' 239 | resp = self.get('/test_acl_uncaught_exception_500', origin='www.example.com') 240 | self.assertEqual(resp.status, 500) 241 | self.assertFalse(ACL_ORIGIN in resp.headers) 242 | 243 | if __name__ == "__main__": 244 | unittest.main() 245 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 2.2.0 4 | - Quick dirty fix for new middleware-registry behaviour on sanic v22.9.0, fixes #64 5 | - Vary 'Origin' header will be added to any existing Vary string on response, fixes #62 6 | 7 | ## 2.2.0b1 8 | - Quick dirty fix for new middleware-registry behaviour on sanic v22.9.0 9 | 10 | ## 2.1.0 11 | - Fix compatibility with Sanic-EXT v22.6.0+ 12 | - Replace deprecated setuptools/`distutils` dependency with `packaging` 13 | 14 | ## 2.0.1 15 | - Fix constructor of CORSErrorHandler, to remove 22.6 deprecation warning. Fixes #57 16 | 17 | ## 2.0.0 18 | - Big changes for Sanic v21.12.0 19 | - Remove the use of Sanic-Plugin-Toolkit. 20 | - This plugin is simple enough that it doesn't need it 21 | - Sanic-Plugin-Toolkit has issues with compatibility in Sanic v21.12.0+ 22 | - Add ability to use sanic-cors as a Sanic-Ext extension (experimental for now...) 23 | 24 | ## 1.0.1 25 | - Fix exception handler compatibility with Sanic v21.9.0 26 | - Bump min SPTK version requirement to v1.2.0 27 | 28 | ## 1.0.0 29 | - Replace Sanic-Plugins-Framework (SPF) with Sanic-Plugin-Toolkit (SPTK) 30 | - Remove python 3.6 compatibility 31 | - Remove Pre-Sanic-21.3 compatibility 32 | - If you need to use sanic <= 21.3, use the Sanic-CORS v0.10 branch 33 | 34 | ## 0.10.0.post3 35 | - Fixes another issue introduced with Sanic 19.12, where automatic_options cannot work when the router is run before 36 | the Sanic-CORS middleware 37 | 38 | ## 0.10.0.post2 39 | - Fixes the issue where the sanic asyncio server write_error routine cannot use an async Exception handler. 40 | - Fixes #38 (again) 41 | 42 | ## 0.10.0.post1 43 | - Fixed the errors seen in Sanic 19.12+ where the CORS exception handler could be triggered 44 | _before_ the request context for a given request is created. 45 | - If on Sanic 19.9+ fallback to using the request.ctx object when request_context is not available 46 | - Fixes #41 47 | 48 | ## 0.10.0 49 | - Fixed catch LookupError when request context doesn't exist 50 | - Release 0.10.0 51 | 52 | ## 0.10.0.b1 53 | - New minimum supported sanic version is 18.12LTS 54 | - Fixed bugs with Sanic 19.12 55 | - Max supported sanic version for this release series is unknown for now. 56 | 57 | ## 58 | _**Note**_, Sanic v19.12.0 (and 19.12.2) _do not_ work with Sanic-CORS 0.9.9 series or earlier. 59 | 60 | ## 0.9.9.post4 61 | This is the last version of sanic-cors to support Sanic 0.8.3 62 | - Update to Sanic 18.12LTS (or higher) to use future Sanic-CORS releases 63 | 64 | Bump Sanic-Plugins-Framework to 0.8.2.post1 to fix a big. 65 | - This is also the last version of SPF to support Sanic 0.8.3 66 | 67 | _**Note**_, Sanic v19.12.0 (and 19.12.2) _do not_ work with Sanic-CORS 0.9.9 series or earlier. 68 | A new version coming out soon will work with sanic v19.12. 69 | 70 | ## 0.9.9.post3 71 | Revert previous patch. Sorry @donjar 72 | 73 | ## 0.9.9.post2 74 | Apply fix for async error handlers. Thanks @donjar 75 | 76 | ## 0.9.9.post1 77 | Actually fix import of headers on latest Sanic versions 78 | 79 | ## 0.9.9 80 | Fix import of headers on latest Sanic versions 81 | 82 | ## 0.9.8.post3 83 | Bump minimum required Sanic-Plugins-Framework version to 0.8.2 84 | - This fixes compatibility with ASGI mode, as well as alternate server runners like gunicorn server runner. 85 | 86 | ## 0.9.8.post2 87 | Bump minimum required Sanic-Plugins-Framework version to 0.8.1 88 | - This allows us to use the new entrypoints feature to advertise the sanic_cors plugin to SPF apps. 89 | - See app_config_example for an example of how this works 90 | 91 | ## 0.9.8.post1 92 | Fix an issue where engineio websockets library can return a response of [], and Sanic will pass that onto response-middlewares. 93 | - We now just check for resp truthiness, so if a resp is None, or False, or [] or any other Falsy value, then we skip applying middleware. 94 | 95 | ## 0.9.8 96 | Bump minimum required Sanic-Plugins-Framework version to 0.7.0 97 | - There were some recent important bugs fixed in SPF, so we want to specify a new min SPF version. 98 | 99 | ## 0.9.7 100 | Changes to allow pickling of the Sanic-CORS Plugin on a Sanic App 101 | - This is to allow Multiprocessing via `workers=` on Windows 102 | Bump minimum required Sanic-Plugins-Framework version to 0.6.4.dev20181101 103 | - This release includes similar pickling fixes in order to solve Windows multiprocessing issues in Sanic-Plugins-Framework 104 | 105 | 106 | ## 0.9.6 107 | Minimum supported sanic is now 0.7.0 (removes legacy support) 108 | Automatic-Options route now sets EVALUATED flag to prevent the response middleware from running again. 109 | Fixed a bug in `response.headers.add()` function all. 110 | Updated all (c)2017 text to (c)2018 (very late, I know) 111 | 112 | ## 0.9.5 113 | Finally a new Sanic is released on PyPI. 114 | Bump min sanic to v0.8.1 115 | Bump sanic-plugins-framework to latest 116 | Use CIMultiDict from Sanic by default, rather than CIDict 117 | Fix a test which broke after the CIDict change 118 | 119 | ## 0.9.4 120 | TODO: Fill in 121 | 122 | ## 0.9.3 123 | TODO: Fill in 124 | 125 | ## 0.9.2 126 | On Sanic 0.6.0, some exceptions can be thrown _after_ a request has finished. In this case, the request context has been destroyed and cannot be accessed. Added a fix for those scenarios. 127 | 128 | ## 0.9.1 129 | Bumped to new version of SPF, to handle tracking multiple Request contexts at once. 130 | 131 | ## 0.9.0 132 | Ported Sanic-CORS to use Sanic-Plugins-Framework! 133 | 134 | This is a big change. Some major architectural changes needed to occur. 135 | 136 | All tests pass, so hopefully there's no fallout in any user facing way. 137 | 138 | No longer tracking SANIC version numbers, we are doing our own versioning now. 139 | 140 | ## 0.6.0.2 141 | Bug fixes, see git commits 142 | 143 | ## 0.6.0.1 144 | Bug fixes, see git commits 145 | 146 | ## 0.6.0.0 147 | Update to Sanic 0.6.0 148 | 149 | ## 0.5.0.0 150 | Update to Sanic 0.5.x 151 | 152 | ## 0.4.1 153 | Update to Sanic 0.4.1 154 | 155 | ## 0.1.0 156 | Initial release of Sanic-Cors, ported to Sanic from Flask-Cors v3.0.2 157 | 158 | # Flask-Cors Change Log 159 | 160 | ## 3.0.2 161 | Fixes Issue #187: regression whereby header (and domain) matching was incorrectly case sensitive. Now it is not, making the behavior identical to 2.X and 1.X. 162 | 163 | ## 3.0.1 164 | Fixes Issue #183: regression whereby regular expressions for origins with an "?" are not properly matched. 165 | 166 | ## 3.0.0 167 | 168 | This release is largely a number of small bug fixes and improvements, along with a default change in behavior, which is technically a breaking change. 169 | 170 | **Breaking Change** 171 | We added an always_send option, enabled by default, which makes Sanic-CORS inject headers even if the request did not have an 'Origin' header. Because this makes debugging far easier, and has very little downside, it has also been set as the default, making it technically a breaking change. If this actually broke something for you, please let me know, and I'll help you work around it. (#156) c7a1ecdad375a796155da6aca6a1f750337175f3 172 | 173 | 174 | Other improvements: 175 | * Adds building of universal wheels (#175) 4674c3d54260f8897bd18e5502509363dcd0d0da 176 | * Makes Sanic-CORS compatible with OAuthLib's custom header class ... (#172) aaaf904845997a3b684bc6677bdfc91656a85a04 177 | * Fixes incorrect substring matches when strings are used as origins or headers (#165) 9cd3f295bd6b0ba87cc5f2afaca01b91ff43e72c 178 | * Fixes logging when unknown options are supplied (#152) bddb13ca6636c5d559ec67a95309c9607a3fcaba 179 | 180 | 181 | ## 2.1.3 182 | Fixes Vary:Origin header sending behavior when regex origins are used. 183 | 184 | 185 | ## 2.1.2 186 | Fixes package installation. Requirements.txt was not included in Manifest. 187 | 188 | 189 | ## 2.1.1 190 | Stop dynamically referecing logger. 191 | 192 | Disable internal logging by default and reduce logging verbosity 193 | 194 | ## 2.1.0 195 | Adds support for Flask Blueprints. 196 | 197 | ## 2.0.1 198 | Fixes Issue #124 where only the first of multiple headers with the same name would be passed through. 199 | 200 | ## 2.0.0 201 | **New Defaults** 202 | 203 | 1. New defaults allow all origins, all headers. 204 | 205 | **Breaking Changes** 206 | 207 | 1. Removed always_send option. 208 | 1. Removed 'headers' option as a backwards-compatible alias for 'allowed_headers' to reduce confusion. 209 | 210 | ## 2.0.0rc1 211 | Would love to get some feedback to make sure there are no unexpected regressions. This should be backwards compatible for most people. 212 | 213 | Update default options and parameters in a backwards incompatible way. 214 | 215 | By default, all headers are now allowed, and only requests with an 216 | Origin header have CORS headers returned. If an Origin header is not 217 | present, no CORS headers are returned. 218 | 219 | Removed the following options: always_send, headers. 220 | 221 | Extension and decorator are now in separate modules sharing a core module. 222 | Test have been moved into the respective tests.extension and tests.decorator 223 | modules. More work to decompose these tests is needed. 224 | 225 | 226 | ## 1.10.3 227 | Release Version 1.10.3 228 | * Adds logging to Sanic-Cors so it is easy to see what is going on and why 229 | * Adds support for compiled regexes as origins 230 | 231 | Big thanks to @michalbachowski and @digitizdat! 232 | 233 | ## 1.10.2 234 | This release fixes the behavior of Access-Control-Allow-Headers and Access-Control-Expose-Headers, which was previously swapped since 1.9.0. 235 | 236 | To further fix the confusion, the `headers` parameter was renamed to more explicitly be `allow_headers`. 237 | 238 | Thanks @maximium for the bug report and implementation! 239 | 240 | ## 1.10.1 241 | This is a bug fix release, fixing: 242 | Incorrect handling of resources and intercept_exceptions App Config options https://github.com/wcdolphin/sanic-cors/issues/84 243 | Issue with functools.partial in 1.10.0 using Python 2.7.9 https://github.com/wcdolphin/sanic-cors/issues/83 244 | 245 | Shoutout to @diiq and @joonathan for reporting these issues! 246 | 247 | ## 1.10.0 248 | * Adds support for returning CORS headers with uncaught exceptions in production so 500s will have expected CORS headers set. This will allow clients to better surface the errors, rather than failing due to security. Reported and tested by @robertfw -- thanks! 249 | * Improved conformance of preflight request handling to W3C spec. 250 | * Code simplification and 100% test coverage :sunglasses: 251 | 252 | ## 1.9.0 253 | * Improves API consistency, allowing a CORS resource of '*' 254 | * Improves documentation of the CORS app extension 255 | * Fixes test import errors on Python 3.4.1 (Thanks @wking ) 256 | 257 | ## 1.8.1 258 | Thanks to @wking's work in PR https://github.com/wcdolphin/sanic-cors/pull/71 `python setup.py test` will now work. 259 | 260 | 261 | ## v1.8.0 262 | Adds support for regular expressions in the list of origins. 263 | 264 | This allows subdomain wildcarding and should be fully backwards compatible. 265 | 266 | Credit to @marcoqu for opening https://github.com/wcdolphin/sanic-cors/issues/54 which inspired this work 267 | 268 | ## Earlier 269 | Prior version numbers were not kept track of in this system. 270 | -------------------------------------------------------------------------------- /sanic_cors/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | core 4 | ~~~~ 5 | Core functionality shared between the extension and the decorator. 6 | 7 | :copyright: (c) 2022 by Ashley Sommer (based on flask-cors by Cory Dolphin). 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | import re 11 | import logging 12 | import collections 13 | from datetime import timedelta 14 | from typing import Dict 15 | try: 16 | # Sanic compat Header from Sanic v19.9.0 and above 17 | from sanic.compat import Header as CIMultiDict 18 | except ImportError: 19 | try: 20 | # Sanic server CIMultiDict from Sanic v0.8.0 and above 21 | from sanic.server import CIMultiDict 22 | except ImportError: 23 | raise RuntimeError("Your version of sanic does not support " 24 | "CIMultiDict") 25 | 26 | LOG = logging.getLogger(__name__) 27 | 28 | # Response Headers 29 | ACL_ORIGIN = 'Access-Control-Allow-Origin' 30 | ACL_METHODS = 'Access-Control-Allow-Methods' 31 | ACL_ALLOW_HEADERS = 'Access-Control-Allow-Headers' 32 | ACL_EXPOSE_HEADERS = 'Access-Control-Expose-Headers' 33 | ACL_CREDENTIALS = 'Access-Control-Allow-Credentials' 34 | ACL_MAX_AGE = 'Access-Control-Max-Age' 35 | 36 | # Request Header 37 | ACL_REQUEST_METHOD = 'Access-Control-Request-Method' 38 | ACL_REQUEST_HEADERS = 'Access-Control-Request-Headers' 39 | 40 | ALL_METHODS = ['GET', 'HEAD', 'POST', 'OPTIONS', 'PUT', 'PATCH', 'DELETE'] 41 | CONFIG_OPTIONS = ['CORS_ORIGINS', 'CORS_METHODS', 'CORS_ALLOW_HEADERS', 42 | 'CORS_EXPOSE_HEADERS', 'CORS_SUPPORTS_CREDENTIALS', 43 | 'CORS_MAX_AGE', 'CORS_SEND_WILDCARD', 44 | 'CORS_AUTOMATIC_OPTIONS', 'CORS_VARY_HEADER', 45 | 'CORS_RESOURCES', 'CORS_INTERCEPT_EXCEPTIONS', 46 | 'CORS_ALWAYS_SEND'] 47 | # Attribute added to request object by decorator to indicate that CORS 48 | # was evaluated, in case the decorator and extension are both applied 49 | # to a view. 50 | # TODO: Refactor these two flags down into one flag. 51 | SANIC_CORS_EVALUATED = '_sanic_cors_e' 52 | SANIC_CORS_SKIP_RESPONSE_MIDDLEWARE = "_sanic_cors_srm" 53 | 54 | # Strange, but this gets the type of a compiled regex, which is otherwise not 55 | # exposed in a public API. 56 | RegexObject = type(re.compile('')) 57 | DEFAULT_OPTIONS = dict(origins='*', 58 | methods=ALL_METHODS, 59 | allow_headers='*', 60 | expose_headers=None, 61 | supports_credentials=False, 62 | max_age=None, 63 | send_wildcard=False, 64 | automatic_options=True, 65 | vary_header=True, 66 | resources=r'/*', 67 | intercept_exceptions=True, 68 | always_send=True) 69 | 70 | 71 | def parse_resources(resources): 72 | if isinstance(resources, dict): 73 | # To make the API more consistent with the decorator, allow a 74 | # resource of '*', which is not actually a valid regexp. 75 | resources = [(re_fix(k), v) for k, v in resources.items()] 76 | 77 | # Sort by regex length to provide consistency of matching and 78 | # to provide a proxy for specificity of match. E.G. longer 79 | # regular expressions are tried first. 80 | def pattern_length(pair): 81 | maybe_regex, _ = pair 82 | return len(get_regexp_pattern(maybe_regex)) 83 | 84 | return sorted(resources, 85 | key=pattern_length, 86 | reverse=True) 87 | 88 | elif isinstance(resources, str): 89 | return [(re_fix(resources), {})] 90 | 91 | elif isinstance(resources, collections.abc.Iterable): 92 | return [(re_fix(r), {}) for r in resources] 93 | 94 | # Type of compiled regex is not part of the public API. Test for this 95 | # at runtime. 96 | elif isinstance(resources, RegexObject): 97 | return [(re_fix(resources), {})] 98 | 99 | else: 100 | raise ValueError("Unexpected value for resources argument.") 101 | 102 | 103 | def get_regexp_pattern(regexp): 104 | """ 105 | Helper that returns regexp pattern from given value. 106 | 107 | :param regexp: regular expression to stringify 108 | :type regexp: _sre.SRE_Pattern or str 109 | :returns: string representation of given regexp pattern 110 | :rtype: str 111 | """ 112 | try: 113 | return regexp.pattern 114 | except AttributeError: 115 | return str(regexp) 116 | 117 | 118 | def get_cors_origins(options, request_origin): 119 | origins = options.get('origins') 120 | wildcard = r'.*' in origins 121 | 122 | # If the Origin header is not present terminate this set of steps. 123 | # The request is outside the scope of this specification.-- W3Spec 124 | if request_origin: 125 | LOG.debug("CORS request received with 'Origin' %s", request_origin) 126 | 127 | # If the allowed origins is an asterisk or 'wildcard', always match 128 | if wildcard and options.get('send_wildcard'): 129 | LOG.debug("Allowed origins are set to '*'. Sending wildcard CORS header.") 130 | return ['*'] 131 | # If the value of the Origin header is a case-sensitive match 132 | # for any of the values in list of origins 133 | elif try_match_any(request_origin, origins): 134 | LOG.debug("The request's Origin header matches. Sending CORS headers.", ) 135 | # Add a single Access-Control-Allow-Origin header, with either 136 | # the value of the Origin header or the string "*" as value. 137 | # -- W3Spec 138 | return [request_origin] 139 | else: 140 | LOG.debug("The request's Origin header does not match any of allowed origins.") 141 | return None 142 | 143 | elif options.get('always_send'): 144 | if wildcard: 145 | # If wildcard is in the origins, even if 'send_wildcard' is False, 146 | # simply send the wildcard. It is the most-likely to be correct 147 | # thing to do (the only other option is to return nothing, which) 148 | # pretty is probably not whawt you want if you specify origins as 149 | # '*' 150 | return ['*'] 151 | else: 152 | # Return all origins that are not regexes. 153 | return sorted([o for o in origins if not probably_regex(o)]) 154 | 155 | # Terminate these steps, return the original request untouched. 156 | else: 157 | LOG.debug("The request did not contain an 'Origin' header. " 158 | "This means the browser or client did not request CORS, ensure the Origin Header is set.") 159 | return None 160 | 161 | 162 | def get_allow_headers(options, acl_request_headers): 163 | if acl_request_headers: 164 | request_headers = [h.strip() for h in acl_request_headers.split(',')] 165 | 166 | # any header that matches in the allow_headers 167 | matching_headers = filter( 168 | lambda h: try_match_any(h, options.get('allow_headers')), 169 | request_headers 170 | ) 171 | 172 | return ', '.join(sorted(matching_headers)) 173 | 174 | return None 175 | 176 | 177 | def get_cors_headers(options: Dict, request_headers: CIMultiDict, request_method): 178 | found_origins_list = request_headers.getall('Origin', None) 179 | found_origins = ", ".join(found_origins_list) if found_origins_list else None 180 | origins_to_set = get_cors_origins(options, found_origins) 181 | 182 | if not origins_to_set: # CORS is not enabled for this route 183 | return CIMultiDict() 184 | 185 | # This is a regular dict here, it gets converted to a CIMultiDict at the bottom of this function. 186 | headers = {} 187 | 188 | for origin in origins_to_set: 189 | # TODO, with CIDict, with will only allow one origin 190 | # With CIMultiDict it should work with multiple 191 | headers[ACL_ORIGIN] = origin 192 | 193 | headers[ACL_EXPOSE_HEADERS] = options.get('expose_headers') 194 | 195 | if options.get('supports_credentials'): 196 | headers[ACL_CREDENTIALS] = 'true' # case sensative 197 | 198 | # This is a preflight request 199 | # http://www.w3.org/TR/cors/#resource-preflight-requests 200 | if request_method == 'OPTIONS': 201 | acl_request_method = request_headers.get(ACL_REQUEST_METHOD, '').upper() 202 | 203 | # If there is no Access-Control-Request-Method header or if parsing 204 | # failed, do not set any additional headers 205 | if acl_request_method and acl_request_method in options.get('methods'): 206 | 207 | # If method is not a case-sensitive match for any of the values in 208 | # list of methods do not set any additional headers and terminate 209 | # this set of steps. 210 | acl_request_headers_list = request_headers.getall(ACL_REQUEST_HEADERS, None) 211 | acl_request_headers = ", ".join(acl_request_headers_list) if acl_request_headers_list else None 212 | headers[ACL_ALLOW_HEADERS] = get_allow_headers(options, acl_request_headers) 213 | headers[ACL_MAX_AGE] = str(options.get('max_age')) # sanic cannot handle integers in header values. 214 | headers[ACL_METHODS] = options.get('methods') 215 | else: 216 | LOG.info("The request's Access-Control-Request-Method header does not match allowed methods. " 217 | "CORS headers will not be applied.") 218 | 219 | # http://www.w3.org/TR/cors/#resource-implementation 220 | if options.get('vary_header'): 221 | # Only set header if the origin returned will vary dynamically, 222 | # i.e. if we are not returning an asterisk, and there are multiple 223 | # origins that can be matched. 224 | if headers[ACL_ORIGIN] == '*': 225 | pass 226 | elif (len(options.get('origins')) > 1 or 227 | len(origins_to_set) > 1 or 228 | any(map(probably_regex, options.get('origins')))): 229 | headers['Vary'] = "Origin" 230 | 231 | return CIMultiDict((k, v) for k, v in headers.items() if v) 232 | 233 | 234 | def set_cors_headers(req, resp, req_context, options): 235 | """ 236 | Performs the actual evaluation of Sanic-CORS options and actually 237 | modifies the response object. 238 | 239 | This function is used in the decorator, the CORS exception wrapper, 240 | and the after_request callback 241 | :param sanic.request.Request req: 242 | 243 | """ 244 | # If CORS has already been evaluated via the decorator, skip 245 | if req_context is not None: 246 | evaluated = getattr(req_context, SANIC_CORS_EVALUATED, False) 247 | if evaluated: 248 | LOG.debug('CORS have been already evaluated, skipping') 249 | return resp 250 | 251 | # `resp` can be None or [] in the case of using Websockets 252 | # however this case should have been handled in the `extension` and `decorator` methods 253 | # before getting here. This is a final failsafe check to prevent crashing 254 | if not resp: 255 | return None 256 | 257 | if resp.headers is None: 258 | resp.headers = CIMultiDict() 259 | 260 | headers_to_set = get_cors_headers(options, req.headers, req.method) 261 | LOG.debug('Settings CORS headers: %s', str(headers_to_set)) 262 | 263 | for k, v in headers_to_set.items(): 264 | # Special case for "Vary" header, we should append it to a comma separated list 265 | if (k == "vary" or k == "Vary") and "vary" in resp.headers: 266 | vary_list = resp.headers.popall("vary") 267 | vary_list.append(v) 268 | new_vary = ", ".join(vary_list) 269 | try: 270 | resp.headers.add('Vary', new_vary) 271 | except Exception: 272 | resp.headers['Vary'] = new_vary 273 | else: 274 | try: 275 | resp.headers.add(k, v) 276 | except Exception: 277 | resp.headers[k] = v 278 | return resp 279 | 280 | 281 | def probably_regex(maybe_regex): 282 | if isinstance(maybe_regex, RegexObject): 283 | return True 284 | else: 285 | common_regex_chars = ['*', '\\',']', '?'] 286 | # Use common characters used in regular expressions as a proxy 287 | # for if this string is in fact a regex. 288 | return any((c in maybe_regex for c in common_regex_chars)) 289 | 290 | 291 | def re_fix(reg): 292 | """ 293 | Replace the invalid regex r'*' with the valid, wildcard regex r'/.*' to 294 | enable the CORS app extension to have a more user friendly api. 295 | """ 296 | return r'.*' if reg == r'*' else reg 297 | 298 | 299 | def try_match_any(inst, patterns): 300 | return any(try_match(inst, pattern) for pattern in patterns) 301 | 302 | 303 | def try_match(request_origin, maybe_regex): 304 | """Safely attempts to match a pattern or string to a request origin.""" 305 | if isinstance(maybe_regex, RegexObject): 306 | return re.match(maybe_regex, request_origin) 307 | elif probably_regex(maybe_regex): 308 | return re.match(maybe_regex, request_origin, flags=re.IGNORECASE) 309 | else: 310 | try: 311 | return request_origin.lower() == maybe_regex.lower() 312 | except AttributeError: 313 | return request_origin == maybe_regex 314 | 315 | 316 | def get_cors_options(appInstance, *dicts): 317 | """ 318 | Compute CORS options for an application by combining the DEFAULT_OPTIONS, 319 | the app's configuration-specified options and any dictionaries passed. The 320 | last specified option wins. 321 | """ 322 | options = DEFAULT_OPTIONS.copy() 323 | options.update(get_app_kwarg_dict(appInstance)) 324 | if dicts: 325 | for d in dicts: 326 | options.update(d) 327 | 328 | return serialize_options(options) 329 | 330 | 331 | def get_app_kwarg_dict(appInstance): 332 | """Returns the dictionary of CORS specific app configurations.""" 333 | # In order to support blueprints which do not have a config attribute 334 | app_config = getattr(appInstance, 'config', {}) 335 | return dict( 336 | (k.lower().replace('cors_', ''), app_config.get(k)) 337 | for k in CONFIG_OPTIONS 338 | if app_config.get(k) is not None 339 | ) 340 | 341 | 342 | def flexible_str(obj): 343 | """ 344 | A more flexible str function which intelligently handles stringifying 345 | strings, lists and other iterables. The results are lexographically sorted 346 | to ensure generated responses are consistent when iterables such as Set 347 | are used. 348 | """ 349 | if obj is None: 350 | return None 351 | elif(not isinstance(obj, str) 352 | and isinstance(obj, collections.abc.Iterable)): 353 | return ', '.join(str(item) for item in sorted(obj)) 354 | else: 355 | return str(obj) 356 | 357 | 358 | def serialize_option(options_dict, key, upper=False): 359 | if key in options_dict: 360 | value = flexible_str(options_dict[key]) 361 | options_dict[key] = value.upper() if upper else value 362 | 363 | 364 | def ensure_iterable(inst): 365 | """ 366 | Wraps scalars or string types as a list, or returns the iterable instance. 367 | """ 368 | if isinstance(inst, str): 369 | return [inst] 370 | elif not isinstance(inst, collections.abc.Iterable): 371 | return [inst] 372 | else: 373 | return inst 374 | 375 | 376 | def sanitize_regex_param(param): 377 | return [re_fix(x) for x in ensure_iterable(param)] 378 | 379 | 380 | def serialize_options(opts): 381 | """ 382 | A helper method to serialize and processes the options dictionary. 383 | """ 384 | options = (opts or {}).copy() 385 | 386 | for key in opts.keys(): 387 | if key not in DEFAULT_OPTIONS: 388 | LOG.warning("Unknown option passed to Sanic-CORS: %s", key) 389 | 390 | # Ensure origins is a list of allowed origins with at least one entry. 391 | options['origins'] = sanitize_regex_param(options.get('origins')) 392 | options['allow_headers'] = sanitize_regex_param(options.get('allow_headers')) 393 | 394 | # This is expressly forbidden by the spec. Raise a value error so people 395 | # don't get burned in production. 396 | if r'.*' in options['origins'] and options['supports_credentials'] and options['send_wildcard']: 397 | raise ValueError("Cannot use supports_credentials in conjunction with" 398 | "an origin string of '*'. See: " 399 | "http://www.w3.org/TR/cors/#resource-requests") 400 | 401 | serialize_option(options, 'expose_headers') 402 | serialize_option(options, 'methods', upper=True) 403 | 404 | if isinstance(options.get('max_age'), timedelta): 405 | options['max_age'] = str(int(options['max_age'].total_seconds())) 406 | 407 | return options 408 | -------------------------------------------------------------------------------- /tests/extension/test_app_extension.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | test 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | 12 | import re 13 | from ..base_test import SanicCorsTestCase 14 | from sanic import Sanic 15 | from sanic.response import json, text 16 | 17 | from sanic_cors import * 18 | from sanic_cors.core import * 19 | 20 | letters = 'abcdefghijklmnopqrstuvwxyz' # string.letters is not PY3 compatible 21 | 22 | class AppExtensionRegexp(SanicCorsTestCase): 23 | def setUp(self): 24 | self.app = Sanic(__name__.replace(".","-")) 25 | CORS(self.app, resources={ 26 | r'/test_list': {'origins': ["http://foo.com", "http://bar.com"]}, 27 | r'/test_string': {'origins': 'http://foo.com'}, 28 | r'/test_set': { 29 | 'origins': set(["http://foo.com", "http://bar.com"]) 30 | }, 31 | r'/test_subdomain_regex': { 32 | 'origins': r"http?://\w*\.?example\.com:?\d*/?.*" 33 | }, 34 | r'/test_regex_list': { 35 | 'origins': [r".*.example.com", r".*.otherexample.com"] 36 | }, 37 | r'/test_regex_mixed_list': { 38 | 'origins': ["http://example.com", r".*.otherexample.com"] 39 | }, 40 | r'/test_send_wildcard_with_origin': { 41 | 'send_wildcard': True 42 | }, 43 | re.compile('/test_compiled_subdomain_\w*'): { 44 | 'origins': re.compile("http://example\d+.com") 45 | }, 46 | r'/test_defaults': {} 47 | }) 48 | 49 | @self.app.route('/test_defaults', methods=['GET', 'HEAD', 'OPTIONS']) 50 | def wildcard(request): 51 | return text('Welcome!') 52 | 53 | @self.app.route('/test_send_wildcard_with_origin', methods=['GET', 'HEAD', 'OPTIONS']) 54 | def send_wildcard_with_origin(request): 55 | return text('Welcome!') 56 | 57 | @self.app.route('/test_list', methods=['GET', 'HEAD', 'OPTIONS']) 58 | def test_list(request): 59 | return text('Welcome!') 60 | 61 | @self.app.route('/test_string', methods=['GET', 'HEAD', 'OPTIONS']) 62 | def test_string(request): 63 | return text('Welcome!') 64 | 65 | @self.app.route('/test_set', methods=['GET', 'HEAD', 'OPTIONS']) 66 | def test_set(request): 67 | return text('Welcome!') 68 | 69 | @self.app.route('/test_subdomain_regex', methods=['GET', 'HEAD', 'OPTIONS']) 70 | def test_set(request): 71 | return text('Welcome!') 72 | 73 | @self.app.route('/test_regex_list', methods=['GET', 'HEAD', 'OPTIONS']) 74 | def test_set(request): 75 | return text('Welcome!') 76 | 77 | @self.app.route('/test_regex_mixed_list', methods=['GET', 'HEAD', 'OPTIONS']) 78 | def test_set(request): 79 | return text('Welcome!') 80 | 81 | @self.app.route('/test_compiled_subdomain_regex', methods=['GET', 'HEAD', 'OPTIONS']) 82 | def test_set(request): 83 | return text('Welcome!') 84 | 85 | def test_defaults_no_origin(self): 86 | ''' If there is no Origin header in the request, 87 | by default the '*' should be sent 88 | ''' 89 | for resp in self.iter_responses('/test_defaults'): 90 | self.assertEqual(resp.headers.get(ACL_ORIGIN), '*') 91 | 92 | def test_defaults_with_origin(self): 93 | ''' If there is an Origin header in the request, the 94 | Access-Control-Allow-Origin header should be included. 95 | ''' 96 | for resp in self.iter_responses('/test_defaults', origin='http://example.com'): 97 | self.assertEqual(resp.status, 200) 98 | self.assertEqual(resp.headers.get(ACL_ORIGIN), 'http://example.com') 99 | 100 | def test_send_wildcard_with_origin(self): 101 | ''' If there is an Origin header in the request, the 102 | Access-Control-Allow-Origin header should be included. 103 | ''' 104 | for resp in self.iter_responses('/test_send_wildcard_with_origin', origin='http://example.com'): 105 | self.assertEqual(resp.status, 200) 106 | self.assertEqual(resp.headers.get(ACL_ORIGIN), '*') 107 | 108 | def test_list_serialized(self): 109 | ''' If there is an Origin header in the request, the 110 | Access-Control-Allow-Origin header should be echoed. 111 | ''' 112 | resp = self.get('/test_list', origin='http://bar.com') 113 | self.assertEqual(resp.headers.get(ACL_ORIGIN),'http://bar.com') 114 | 115 | def test_string_serialized(self): 116 | ''' If there is an Origin header in the request, 117 | the Access-Control-Allow-Origin header should be echoed back. 118 | ''' 119 | resp = self.get('/test_string', origin='http://foo.com') 120 | self.assertEqual(resp.headers.get(ACL_ORIGIN), 'http://foo.com') 121 | 122 | def test_set_serialized(self): 123 | ''' If there is an Origin header in the request, 124 | the Access-Control-Allow-Origin header should be echoed back. 125 | ''' 126 | resp = self.get('/test_set', origin='http://bar.com') 127 | 128 | allowed = resp.headers.get(ACL_ORIGIN) 129 | # Order is not garaunteed 130 | self.assertEqual(allowed, 'http://bar.com') 131 | 132 | def test_not_matching_origins(self): 133 | for resp in self.iter_responses('/test_list', origin="http://bazz.com"): 134 | self.assertFalse(ACL_ORIGIN in resp.headers) 135 | 136 | def test_subdomain_regex(self): 137 | for sub in letters: 138 | domain = "http://%s.example.com" % sub 139 | for resp in self.iter_responses('/test_subdomain_regex', 140 | headers={'origin': domain}): 141 | self.assertEqual(domain, resp.headers.get(ACL_ORIGIN)) 142 | 143 | def test_compiled_subdomain_regex(self): 144 | for sub in [1, 100, 200]: 145 | domain = "http://example%s.com" % sub 146 | for resp in self.iter_responses('/test_compiled_subdomain_regex', 147 | headers={'origin': domain}): 148 | self.assertEqual(domain, resp.headers.get(ACL_ORIGIN)) 149 | for resp in self.iter_responses('/test_compiled_subdomain_regex', 150 | headers={'origin': "http://examplea.com"}): 151 | self.assertEqual(None, resp.headers.get(ACL_ORIGIN)) 152 | 153 | def test_regex_list(self): 154 | for parent in 'example.com', 'otherexample.com': 155 | for sub in letters: 156 | domain = "http://%s.%s.com" % (sub, parent) 157 | for resp in self.iter_responses('/test_regex_list', 158 | headers={'origin': domain}): 159 | self.assertEqual(domain, resp.headers.get(ACL_ORIGIN)) 160 | 161 | def test_regex_mixed_list(self): 162 | ''' 163 | Tests the corner case occurs when the send_always setting is True 164 | and no Origin header in the request, it is not possible to match 165 | the regular expression(s) to determine the correct 166 | Access-Control-Allow-Origin header to be returned. Instead, the 167 | list of origins is serialized, and any strings which seem like 168 | regular expressions (e.g. are not a '*' and contain either '*' 169 | or '?') will be skipped. 170 | 171 | Thus, the list of returned Access-Control-Allow-Origin header 172 | is garaunteed to be 'null', the origin or "*", as per the w3 173 | http://www.w3.org/TR/cors/#access-control-allow-origin-response-header 174 | 175 | ''' 176 | for sub in letters: 177 | domain = "http://%s.otherexample.com" % sub 178 | for resp in self.iter_responses('/test_regex_mixed_list', 179 | origin=domain): 180 | self.assertEqual(domain, resp.headers.get(ACL_ORIGIN)) 181 | 182 | self.assertEqual("http://example.com", 183 | self.get('/test_regex_mixed_list', origin='http://example.com').headers.get(ACL_ORIGIN)) 184 | 185 | 186 | class AppExtensionList(SanicCorsTestCase): 187 | def setUp(self): 188 | self.app = Sanic(__name__.replace(".","-")) 189 | CORS(self.app, resources=[r'/test_exposed', r'/test_other_exposed'], 190 | origins=['http://foo.com', 'http://bar.com']) 191 | 192 | @self.app.route('/test_unexposed', methods=['GET', 'HEAD', 'OPTIONS']) 193 | def unexposed(request): 194 | return text('Not exposed over CORS!') 195 | 196 | @self.app.route('/test_exposed', methods=['GET', 'HEAD', 'OPTIONS']) 197 | def exposed1(request): 198 | return text('Welcome!') 199 | 200 | @self.app.route('/test_other_exposed', methods=['GET', 'HEAD', 'OPTIONS']) 201 | def exposed2(request): 202 | return text('Welcome!') 203 | 204 | def test_exposed(self): 205 | for resp in self.iter_responses('/test_exposed', origin='http://foo.com'): 206 | self.assertEqual(resp.status, 200) 207 | self.assertEqual(resp.headers.get(ACL_ORIGIN), 'http://foo.com') 208 | 209 | def test_other_exposed(self): 210 | for resp in self.iter_responses('/test_other_exposed', origin='http://bar.com'): 211 | self.assertEqual(resp.status, 200) 212 | self.assertEqual(resp.headers.get(ACL_ORIGIN), 'http://bar.com') 213 | 214 | def test_unexposed(self): 215 | for resp in self.iter_responses('/test_unexposed', origin='http://foo.com'): 216 | self.assertEqual(resp.status, 200) 217 | self.assertFalse(ACL_ORIGIN in resp.headers) 218 | 219 | 220 | class AppExtensionString(SanicCorsTestCase): 221 | def setUp(self): 222 | self.app = Sanic(__name__.replace(".","-")) 223 | CORS(self.app, resources=r'/api/*', 224 | headers='Content-Type', 225 | expose_headers='X-Total-Count', 226 | automatic_options=False, 227 | origins='http://bar.com') 228 | 229 | @self.app.route('/api/v1/foo', methods=['GET', 'HEAD', 'OPTIONS']) 230 | def exposed1(request): 231 | return json({"success": True}) 232 | 233 | @self.app.route('/api/v1/bar', methods=['GET', 'HEAD', 'OPTIONS']) 234 | def exposed2(request): 235 | return json({"success": True}) 236 | 237 | @self.app.route('/api/v1/special', methods=['GET', 'HEAD', 'OPTIONS']) 238 | @cross_origin(self.app, origins='http://foo.com', automatic_options=True) 239 | def overridden(request): 240 | return json({"special": True}) 241 | 242 | @self.app.route('/', methods=['GET', 'HEAD', 'OPTIONS']) 243 | def index(request): 244 | return text('Welcome') 245 | 246 | def test_exposed(self): 247 | for path in '/api/v1/foo', '/api/v1/bar': 248 | for resp in self.iter_responses(path, origin='http://bar.com'): 249 | self.assertEqual(resp.status, 200) 250 | self.assertEqual(resp.headers.get(ACL_ORIGIN), 'http://bar.com') 251 | self.assertEqual(resp.headers.get(ACL_EXPOSE_HEADERS), 252 | 'X-Total-Count') 253 | for resp in self.iter_responses(path, origin='http://foo.com'): 254 | self.assertEqual(resp.status, 200) 255 | self.assertFalse(ACL_ORIGIN in resp.headers) 256 | self.assertFalse(ACL_EXPOSE_HEADERS in resp.headers) 257 | 258 | def test_unexposed(self): 259 | for resp in self.iter_responses('/', origin='http://bar.com'): 260 | self.assertEqual(resp.status, 200) 261 | self.assertFalse(ACL_ORIGIN in resp.headers) 262 | self.assertFalse(ACL_EXPOSE_HEADERS in resp.headers) 263 | 264 | def test_override(self): 265 | for resp in self.iter_responses('/api/v1/special', origin='http://foo.com'): 266 | self.assertEqual(resp.status, 200) 267 | self.assertEqual(resp.headers.get(ACL_ORIGIN), 'http://foo.com') 268 | 269 | self.assertFalse(ACL_EXPOSE_HEADERS in resp.headers) 270 | 271 | for resp in self.iter_responses('/api/v1/special', origin='http://bar.com'): 272 | self.assertEqual(resp.status, 200) 273 | self.assertFalse(ACL_ORIGIN in resp.headers) 274 | self.assertFalse(ACL_EXPOSE_HEADERS in resp.headers) 275 | 276 | 277 | class AppExtensionError(SanicCorsTestCase): 278 | def test_value_error(self): 279 | try: 280 | app = Sanic(__name__.replace(".","-")) 281 | CORS(app, resources=5) 282 | self.assertTrue(False, "Should've raised a value error") 283 | except ValueError: 284 | pass 285 | 286 | 287 | class AppExtensionDefault(SanicCorsTestCase): 288 | def test_default(self): 289 | ''' 290 | By default match all. 291 | ''' 292 | 293 | self.app = Sanic(__name__.replace(".","-")) 294 | CORS(self.app) 295 | 296 | @self.app.route('/', methods=['GET', 'HEAD', 'OPTIONS']) 297 | def index(request): 298 | return text('Welcome') 299 | 300 | for resp in self.iter_responses('/', origin='http://foo.com'): 301 | self.assertEqual(resp.status, 200) 302 | self.assertTrue(ACL_ORIGIN in resp.headers) 303 | 304 | 305 | class AppExtensionExampleApp(SanicCorsTestCase): 306 | def setUp(self): 307 | self.app = Sanic(__name__.replace(".","-")) 308 | CORS(self.app, resources={ 309 | r'/api/*': {'origins': ['http://blah.com', 'http://foo.bar']} 310 | }) 311 | 312 | @self.app.route('/', methods=['GET', 'HEAD', 'OPTIONS']) 313 | def index(request): 314 | return text('') 315 | 316 | @self.app.route('/api/foo', methods=['GET', 'HEAD', 'OPTIONS']) 317 | def test_wildcard(request): 318 | return text('') 319 | 320 | @self.app.route('/api/', methods=['GET', 'HEAD', 'OPTIONS']) 321 | def test_exact_match(request): 322 | return text('') 323 | 324 | def test_index(self): 325 | ''' 326 | If regex does not match, do not set CORS 327 | ''' 328 | for resp in self.iter_responses('/', origin='http://foo.bar'): 329 | self.assertFalse(ACL_ORIGIN in resp.headers) 330 | 331 | def test_wildcard(self): 332 | ''' 333 | Match anything matching the path /api/* with an origin 334 | of 'http://blah.com' or 'http://foo.bar' 335 | ''' 336 | for origin in ['http://foo.bar', 'http://blah.com']: 337 | for resp in self.iter_responses('/api/foo', origin=origin): 338 | self.assertTrue(ACL_ORIGIN in resp.headers) 339 | self.assertEqual(origin, resp.headers.get(ACL_ORIGIN)) 340 | 341 | def test_exact_match(self): 342 | ''' 343 | Match anything matching the path /api/* with an origin 344 | of 'http://blah.com' or 'http://foo.bar' 345 | ''' 346 | for origin in ['http://foo.bar', 'http://blah.com']: 347 | for resp in self.iter_responses('/api/', origin=origin): 348 | self.assertTrue(ACL_ORIGIN in resp.headers) 349 | self.assertEqual(origin, resp.headers.get(ACL_ORIGIN)) 350 | 351 | 352 | class AppExtensionCompiledRegexp(SanicCorsTestCase): 353 | def test_compiled_regex(self): 354 | ''' 355 | Ensure we do not error if the user specifies an compiled regular 356 | expression. 357 | ''' 358 | import re 359 | self.app = Sanic(__name__.replace(".","-")) 360 | CORS(self.app, resources=re.compile('/api/.*')) 361 | 362 | @self.app.route('/', methods=['GET', 'HEAD', 'OPTIONS']) 363 | def index(request): 364 | return text('Welcome') 365 | 366 | @self.app.route('/api/v1', methods=['GET', 'HEAD', 'OPTIONS']) 367 | def example(request): 368 | return text('Welcome') 369 | 370 | for resp in self.iter_responses('/'): 371 | self.assertFalse(ACL_ORIGIN in resp.headers) 372 | 373 | for resp in self.iter_responses('/api/v1', origin='http://foo.com'): 374 | self.assertTrue(ACL_ORIGIN in resp.headers) 375 | 376 | 377 | class AppExtensionBadRegexp(SanicCorsTestCase): 378 | def test_value_error(self): 379 | ''' 380 | Ensure we do not error if the user specifies an bad regular 381 | expression. 382 | ''' 383 | 384 | self.app = Sanic(__name__.replace(".","-")) 385 | CORS(self.app, resources="[") 386 | 387 | @self.app.route('/', methods=['GET', 'HEAD', 'OPTIONS']) 388 | def index(request): 389 | return text('Welcome') 390 | 391 | for resp in self.iter_responses('/'): 392 | self.assertEqual(resp.status, 200) 393 | 394 | 395 | if __name__ == "__main__": 396 | unittest.main() 397 | -------------------------------------------------------------------------------- /sanic_cors/extension.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | extension 4 | ~~~~ 5 | Sanic-CORS is a simple extension to Sanic allowing you to support cross 6 | origin resource sharing (CORS) using a simple decorator. 7 | 8 | :copyright: (c) 2022 by Ashley Sommer (based on flask-cors by Cory Dolphin). 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | from asyncio import iscoroutinefunction 12 | from functools import update_wrapper, partial 13 | from inspect import isawaitable 14 | from types import SimpleNamespace 15 | from typing import Optional, Dict 16 | 17 | from sanic import Sanic, exceptions, response, __version__ as sanic_version, Blueprint 18 | from sanic.exceptions import MethodNotSupported, NotFound 19 | from sanic.handlers import ErrorHandler 20 | from sanic.log import logger 21 | from sanic.models.futures import FutureMiddleware 22 | 23 | from .core import * 24 | from packaging.version import Version 25 | import logging 26 | 27 | 28 | try: 29 | import sanic_ext 30 | from sanic_ext.extensions.base import Extension 31 | from sanic_ext.config import Config 32 | SANIC_EXT_VERSION = Version(sanic_ext.__version__) 33 | use_ext = True 34 | except ImportError: 35 | use_ext = False 36 | Extension = object 37 | SANIC_EXT_VERSION = Version("0.0.0") 38 | from sanic.config import Config 39 | 40 | try: 41 | from sanic.middleware import Middleware, MiddlewareLocation 42 | except ImportError: 43 | Middleware = object 44 | MiddlewareLocation = object 45 | 46 | SANIC_VERSION = Version(sanic_version) 47 | SANIC_21_9_0 = Version("21.9.0") 48 | SANIC_22_9_0 = Version("22.9.0") 49 | SANIC_EXT_22_6_0 = Version("22.6.0") 50 | 51 | USE_ASYNC_EXCEPTION_HANDLER = False 52 | 53 | class CORS(Extension): 54 | """ 55 | Initializes Cross Origin Resource sharing for the application. The 56 | arguments are identical to :py:func:`cross_origin`, with the addition of a 57 | `resources` parameter. The resources parameter defines a series of regular 58 | expressions for resource paths to match and optionally, the associated 59 | options to be applied to the particular resource. These options are 60 | identical to the arguments to :py:func:`cross_origin`. 61 | 62 | The settings for CORS are determined in the following order 63 | 64 | 1. Resource level settings (e.g when passed as a dictionary) 65 | 2. Keyword argument settings 66 | 3. App level configuration settings (e.g. CORS_*) 67 | 4. Default settings 68 | 69 | Note: as it is possible for multiple regular expressions to match a 70 | resource path, the regular expressions are first sorted by length, 71 | from longest to shortest, in order to attempt to match the most 72 | specific regular expression. This allows the definition of a 73 | number of specific resource options, with a wildcard fallback 74 | for all other resources. 75 | 76 | :param resources: 77 | The series of regular expression and (optionally) associated CORS 78 | options to be applied to the given resource path. 79 | 80 | If the argument is a dictionary, it's keys must be regular expressions, 81 | and the values must be a dictionary of kwargs, identical to the kwargs 82 | of this function. 83 | 84 | If the argument is a list, it is expected to be a list of regular 85 | expressions, for which the app-wide configured options are applied. 86 | 87 | If the argument is a string, it is expected to be a regular expression 88 | for which the app-wide configured options are applied. 89 | 90 | Default : Match all and apply app-level configuration 91 | 92 | :type resources: dict, iterable or string 93 | 94 | :param origins: 95 | The origin, or list of origins to allow requests from. 96 | The origin(s) may be regular expressions, case-sensitive strings, 97 | or else an asterisk 98 | 99 | Default : '*' 100 | :type origins: list, string or regex 101 | 102 | :param methods: 103 | The method or list of methods which the allowed origins are allowed to 104 | access for non-simple requests. 105 | 106 | Default : [GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE] 107 | :type methods: list or string 108 | 109 | :param expose_headers: 110 | The header or list which are safe to expose to the API of a CORS API 111 | specification. 112 | 113 | Default : None 114 | :type expose_headers: list or string 115 | 116 | :param allow_headers: 117 | The header or list of header field names which can be used when this 118 | resource is accessed by allowed origins. The header(s) may be regular 119 | expressions, case-sensitive strings, or else an asterisk. 120 | 121 | Default : '*', allow all headers 122 | :type allow_headers: list, string or regex 123 | 124 | :param supports_credentials: 125 | Allows users to make authenticated requests. If true, injects the 126 | `Access-Control-Allow-Credentials` header in responses. This allows 127 | cookies and credentials to be submitted across domains. 128 | 129 | :note: This option cannot be used in conjuction with a '*' origin 130 | 131 | Default : False 132 | :type supports_credentials: bool 133 | 134 | :param max_age: 135 | The maximum time for which this CORS request maybe cached. This value 136 | is set as the `Access-Control-Max-Age` header. 137 | 138 | Default : None 139 | :type max_age: timedelta, integer, string or None 140 | 141 | :param send_wildcard: If True, and the origins parameter is `*`, a wildcard 142 | `Access-Control-Allow-Origin` header is sent, rather than the 143 | request's `Origin` header. 144 | 145 | Default : False 146 | :type send_wildcard: bool 147 | 148 | :param vary_header: 149 | If True, the header Vary: Origin will be returned as per the W3 150 | implementation guidelines. 151 | 152 | Setting this header when the `Access-Control-Allow-Origin` is 153 | dynamically generated (e.g. when there is more than one allowed 154 | origin, and an Origin than '*' is returned) informs CDNs and other 155 | caches that the CORS headers are dynamic, and cannot be cached. 156 | 157 | If False, the Vary header will never be injected or altered. 158 | 159 | Default : True 160 | :type vary_header: bool 161 | """ 162 | 163 | name: str = "SanicCORS" 164 | 165 | def __init__(self, app: Optional[Sanic] = None, config: Optional[Config] = None, *args, **kwargs): 166 | if SANIC_21_9_0 > SANIC_VERSION: 167 | raise RuntimeError( 168 | "You cannot use this version of Sanic-CORS with " 169 | "Sanic earlier than v21.9.0") 170 | self._options = kwargs 171 | if use_ext: 172 | if SANIC_EXT_22_6_0 > SANIC_EXT_VERSION: 173 | if app is None: 174 | raise RuntimeError("Sanic-CORS Extension not registered on app properly. " 175 | "Please upgrade to newer sanic-ext version.") 176 | super(CORS, self).__init__(app, config) 177 | else: 178 | super(CORS, self).__init__() 179 | else: 180 | super(CORS, self).__init__(*args) 181 | self.app = app 182 | self.config = config or {} 183 | bootstrap = SimpleNamespace() 184 | bootstrap.app = self.app 185 | bootstrap.config = self.config 186 | self.startup(bootstrap) 187 | 188 | def log(self, level, message, *args, exc_info=None, **kwargs): 189 | msg = f"Sanic-CORS: {message}" 190 | logger.log(level, msg, *args, exc_info=exc_info, **kwargs) 191 | 192 | 193 | def label(self): 194 | return "Sanic-CORS" 195 | 196 | def startup(self, bootstrap): 197 | """ 198 | Used by sanic-ext to start up an extension 199 | """ 200 | if bootstrap.app != self.app: 201 | raise RuntimeError("Sanic-CORS bootstrap got the wrong app instance. Is sanic-ext enabled properly?") 202 | _options = self._options 203 | cors_options = _options or bootstrap.config.get("CORS_OPTIONS", {}) 204 | no_startup = cors_options.pop("no_startup", None) 205 | if no_startup: 206 | return 207 | self.app.ctx.sanic_cors = context = SimpleNamespace() 208 | context._options = cors_options 209 | context.log = self.log 210 | # turn off built-in sanic-ext CORS 211 | bootstrap.config["CORS"] = False 212 | self.init_app(context) 213 | 214 | def on_before_server_start(self, app, loop=None): 215 | # use self.app instead of app, because self.app might be a blueprint 216 | context = self.app.ctx.sanic_cors 217 | if not isinstance(self.app, Blueprint) and (SANIC_22_9_0 > SANIC_VERSION): 218 | _ = _make_cors_request_middleware_function(self.app, context=context) 219 | _ = _make_cors_response_middleware_function(self.app, context=context) 220 | 221 | def init_app(self, context, *args, **kwargs): 222 | app = self.app 223 | log = context.log 224 | _options = context._options 225 | debug = partial(log, logging.DEBUG) 226 | # The resources and options may be specified in the App Config, the CORS constructor 227 | # or the kwargs to the call to init_app. 228 | options = get_cors_options(app, _options, kwargs) 229 | 230 | # Flatten our resources into a list of the form 231 | # (pattern_or_regexp, dictionary_of_options) 232 | resources = parse_resources(options.get('resources')) 233 | 234 | # Compute the options for each resource by combining the options from 235 | # the app's configuration, the constructor, the kwargs to init_app, and 236 | # finally the options specified in the resources dictionary. 237 | resources = [ 238 | (pattern, get_cors_options(app, options, opts)) 239 | for (pattern, opts) in resources 240 | ] 241 | context.options = options 242 | context.resources = resources 243 | # Create a human readable form of these resources by converting the compiled 244 | # regular expressions into strings. 245 | resources_human = dict([(get_regexp_pattern(pattern), opts) 246 | for (pattern, opts) in resources]) 247 | debug("Configuring CORS with resources: {}".format(resources_human)) 248 | 249 | if isinstance(app, Blueprint): 250 | # skip error handler override on a blueprint 251 | # register the middlewares early, on a blueprint 252 | _make_cors_request_middleware_function(app, context=context) 253 | _make_cors_response_middleware_function(app, context=context) 254 | else: 255 | if hasattr(app, "error_handler"): 256 | cors_error_handler = CORSErrorHandler(context, app.error_handler) 257 | setattr(app, "error_handler", cors_error_handler) 258 | if SANIC_22_9_0 > SANIC_VERSION: 259 | app.listener("before_server_start")(self.on_before_server_start) 260 | else: 261 | # Sanic >= v22.9.0 cannot set routes in before_server_start 262 | # so run it now (we can assign priorities, so should be fine) 263 | _make_cors_request_middleware_function(app, context=context) 264 | _make_cors_response_middleware_function(app, context=context) 265 | 266 | async def route_wrapper(self, route, req, app, request_args, request_kw, 267 | *decorator_args, **decorator_kw): 268 | _options = decorator_kw 269 | options = get_cors_options(app, _options) 270 | if options.get('automatic_options', True) and req.method == 'OPTIONS': 271 | resp = response.HTTPResponse() 272 | else: 273 | resp = route(req, *request_args, **request_kw) 274 | while isawaitable(resp): 275 | resp = await resp 276 | # resp can be `None` or `[]` if using Websockets 277 | if not resp: 278 | return None 279 | try: 280 | request_context = req.ctx 281 | except (AttributeError, LookupError): 282 | request_context = None 283 | set_cors_headers(req, resp, request_context, options) 284 | if request_context is not None: 285 | setattr(request_context, SANIC_CORS_EVALUATED, "1") 286 | else: 287 | logging.log(logging.DEBUG, "Cannot access a sanic request " 288 | "context. Has request started? Is request ended?") 289 | return resp 290 | 291 | def unapplied_cors_request_middleware(req, context=None): 292 | if req.method == 'OPTIONS': 293 | try: 294 | path = req.path 295 | except AttributeError: 296 | path = req.url 297 | resources = context.resources 298 | log = context.log 299 | debug = partial(log, logging.DEBUG) 300 | for res_regex, res_options in resources: 301 | if res_options.get('automatic_options', True) and \ 302 | try_match(path, res_regex): 303 | debug("Request to '{:s}' matches CORS resource '{}'. " 304 | "Using options: {}".format( 305 | path, get_regexp_pattern(res_regex), res_options)) 306 | resp = response.HTTPResponse() 307 | 308 | try: 309 | request_context = req.ctx 310 | except (AttributeError, LookupError): 311 | request_context = None 312 | context.log(logging.DEBUG, "Cannot access a sanic request context. Has request started? Is request ended?") 313 | set_cors_headers(req, resp, request_context, res_options) 314 | if request_context is not None: 315 | setattr(request_context, SANIC_CORS_EVALUATED, "1") 316 | return resp 317 | else: 318 | debug('No CORS rule matches') 319 | 320 | 321 | async def unapplied_cors_response_middleware(req, resp, context=None): 322 | log = context.log 323 | debug = partial(log, logging.DEBUG) 324 | # `resp` can be None or [] in the case of using Websockets 325 | if not resp: 326 | return False 327 | try: 328 | request_context = req.ctx 329 | except (AttributeError, LookupError): 330 | debug("Cannot find the request context. Is request already finished? Is request not started?") 331 | request_context = None 332 | if request_context is not None: 333 | # If CORS headers are set in the CORS error handler 334 | if getattr(request_context, 335 | SANIC_CORS_SKIP_RESPONSE_MIDDLEWARE, False): 336 | debug('CORS was handled in the exception handler, skipping') 337 | return False 338 | 339 | # If CORS headers are set in a view decorator, pass 340 | elif getattr(request_context, SANIC_CORS_EVALUATED, False): 341 | debug('CORS have been already evaluated, skipping') 342 | return False 343 | try: 344 | path = req.path 345 | except AttributeError: 346 | path = req.url 347 | 348 | resources = context.resources 349 | for res_regex, res_options in resources: 350 | if try_match(path, res_regex): 351 | debug("Request to '{}' matches CORS resource '{:s}'. Using options: {}".format( 352 | path, get_regexp_pattern(res_regex), res_options)) 353 | set_cors_headers(req, resp, request_context, res_options) 354 | if request_context is not None: 355 | setattr(request_context, SANIC_CORS_EVALUATED, "1") 356 | break 357 | else: 358 | debug('No CORS rule matches') 359 | 360 | def _make_cors_request_middleware_function(app, context=None): 361 | """If app is a blueprint, this function is executed when the CORS extension is initialized, it can insert 362 | the middleware into the correct location in the blueprint's future_middleware at any time. 363 | If app is a Sanic server, this function is executed by the before_server_start callback, it inserts the middleware 364 | at the correct location at that point in time. 365 | The exception is with Sanic v22.9+, middlwares are finalized _before_ the before_server_start event, so we must 366 | run this function at plugin initialization time, but v22.9+ has priorities, so it can be inserted with priority. 367 | """ 368 | mw = update_wrapper(partial(unapplied_cors_request_middleware, context=context), unapplied_cors_request_middleware) 369 | _old_name = getattr(mw, "__name__", None) 370 | if _old_name: 371 | setattr(mw, "__name__", str(_old_name).replace("unapplied_", "")) 372 | _old_qname = getattr(mw, "__qualname__", None) 373 | if _old_qname: 374 | setattr(mw, "__qualname__", str(_old_qname).replace("unapplied_", "")) 375 | if SANIC_22_9_0 <= SANIC_VERSION: 376 | new_mw = Middleware(mw, MiddlewareLocation.REQUEST, priority=99) 377 | else: 378 | new_mw = mw 379 | 380 | future_middleware = FutureMiddleware(new_mw, "request") 381 | if isinstance(app, Blueprint): 382 | bp = app 383 | if bp.registered: 384 | context.log(logging.WARNING, "Blueprint is already registered, cannot put the CORS middleware at the start of the chain.") 385 | bp.middleware("request")(mw) 386 | else: 387 | bp._future_middleware.insert(0, future_middleware) 388 | else: 389 | # Put at start of request middlewares 390 | app._future_middleware.insert(0, future_middleware) 391 | app.request_middleware.appendleft(new_mw) 392 | 393 | def _make_cors_response_middleware_function(app, context=None): 394 | """If app is a blueprint, this function is executed when the CORS extension is initialized, it can insert 395 | the middleware into the correct location in the blueprint's future_middleware at any time. 396 | If app is a Sanic server, this function is executed by the before_server_start callback, it inserts the middleware 397 | at the correct location at that point in time. 398 | The exception is with Sanic v22.9+, middlwares are finalized _before_ the before_server_start event, so we must 399 | run this function at plugin initialization time, but v22.9+ has priorities, so it can be inserted with priority. 400 | """ 401 | mw = update_wrapper(partial(unapplied_cors_response_middleware, context=context), unapplied_cors_request_middleware) 402 | _old_name = getattr(mw, "__name__", None) 403 | if _old_name: 404 | setattr(mw, "__name__", str(_old_name).replace("unapplied_", "")) 405 | _old_qname = getattr(mw, "__qualname__", None) 406 | if _old_qname: 407 | setattr(mw, "__qualname__", str(_old_qname).replace("unapplied_", "")) 408 | if SANIC_22_9_0 <= SANIC_VERSION: 409 | new_mw = Middleware(mw, MiddlewareLocation.RESPONSE, priority=999) 410 | else: 411 | new_mw = mw 412 | future_middleware = FutureMiddleware(new_mw, "response") 413 | if isinstance(app, Blueprint): 414 | bp = app 415 | if bp.registered: 416 | context.log(logging.WARNING, "Blueprint is already registered, adding CORS response middleware to end of the chain.") 417 | bp.middleware("request")(mw) 418 | else: 419 | bp._future_middleware.append(future_middleware) 420 | else: 421 | # Put at start of end of response middlewares 422 | app._future_middleware.append(future_middleware) 423 | app.response_middleware.append(new_mw) 424 | 425 | class CORSErrorHandler(ErrorHandler): 426 | @classmethod 427 | def _apply_cors_to_exception(cls, ctx, req, resp): 428 | try: 429 | path = req.path 430 | except AttributeError: 431 | path = req.url 432 | if path is not None: 433 | resources = ctx.resources 434 | log = ctx.log 435 | debug = partial(log, logging.DEBUG) 436 | try: 437 | request_context = req.ctx 438 | except (AttributeError, LookupError): 439 | request_context = None 440 | for res_regex, res_options in resources: 441 | if try_match(path, res_regex): 442 | debug( 443 | "Request to '{:s}' matches CORS resource '{}'. " 444 | "Using options: {}".format( 445 | path, get_regexp_pattern(res_regex), 446 | res_options)) 447 | set_cors_headers(req, resp, request_context, res_options) 448 | break 449 | else: 450 | debug('No CORS rule matches') 451 | else: 452 | pass 453 | 454 | def __new__(cls, *args, **kwargs): 455 | self = super(CORSErrorHandler, cls).__new__(cls) 456 | if USE_ASYNC_EXCEPTION_HANDLER: 457 | self.response = self.async_response 458 | else: 459 | self.response = self.sync_response 460 | return self 461 | 462 | def __init__(self, context, orig_handler): 463 | super(CORSErrorHandler, self).__init__() 464 | self.orig_handler = orig_handler 465 | self.ctx = context 466 | 467 | def add(self, exception, handler, route_names=None): 468 | self.orig_handler.add(exception, handler, route_names=route_names) 469 | 470 | def lookup(self, exception, route_name=None): 471 | return self.orig_handler.lookup(exception, route_name=route_name) 472 | 473 | # wrap app's original exception response function 474 | # so that error responses have proper CORS headers 475 | @classmethod 476 | def wrapper(cls, f, ctx, req, e): 477 | opts = ctx.options 478 | log = ctx.log 479 | 480 | # Check if we got an error because method was OPTIONS, 481 | # but that wasn't listed in methods, but we have automatic_options enabled 482 | if (req is not None and 483 | isinstance(e, MethodNotSupported) and req.method == "OPTIONS" and 484 | opts.get('automatic_options', True)): 485 | # A very specific set of requirements to trigger this kind of 486 | # automatic-options resp 487 | resp = response.HTTPResponse() 488 | else: 489 | do_await = iscoroutinefunction(f) 490 | # get response from the original handler 491 | resp = f(req, e) 492 | if do_await: 493 | log(logging.DEBUG, 494 | "Found an async Exception handler response. " 495 | "Cannot apply CORS to it. Passing it on.") 496 | return resp 497 | # SanicExceptions are equiv to Flask Aborts, 498 | # always apply CORS to them. 499 | if (req is not None and resp is not None) and \ 500 | (isinstance(e, exceptions.SanicException) or 501 | opts.get('intercept_exceptions', True)): 502 | try: 503 | cls._apply_cors_to_exception(ctx, req, resp) 504 | except AttributeError: 505 | # not sure why certain exceptions doesn't have 506 | # an accompanying request 507 | pass 508 | if req is None: 509 | return resp 510 | # These exceptions have normal CORS middleware applied automatically. 511 | # So set a flag to skip our manual application of the middleware. 512 | try: 513 | request_context = req.ctx 514 | except (LookupError, AttributeError): 515 | # On Sanic 19.12.0, a NotFound error can be thrown _before_ 516 | # the request_context is set up. This is a fallback routine: 517 | if not isinstance(e, (NotFound, MethodNotSupported)): 518 | log(logging.DEBUG, 519 | "Cannot find the request context. Is request started? " 520 | "Is request already finished?") 521 | request_context = None 522 | if request_context is not None: 523 | setattr(request_context, 524 | SANIC_CORS_SKIP_RESPONSE_MIDDLEWARE, "1") 525 | return resp 526 | 527 | async def async_response(self, request, exception): 528 | orig_resp_handler = self.orig_handler.response 529 | return await self.wrapper(orig_resp_handler, self.ctx, request, exception) 530 | 531 | def sync_response(self, request, exception): 532 | orig_resp_handler = self.orig_handler.response 533 | return self.wrapper(orig_resp_handler, self.ctx, request, exception) 534 | 535 | --------------------------------------------------------------------------------