├── src └── quart_cors │ ├── py.typed │ └── __init__.py ├── setup.cfg ├── .gitignore ├── .github └── workflows │ ├── publish.yml │ └── ci.yml ├── tox.ini ├── LICENSE ├── tests ├── test_websocket.py ├── test_simple_request.py ├── test_overrides.py ├── test_preflight_request.py └── test_basic.py ├── CHANGELOG.rst ├── pyproject.toml └── README.rst /src/quart_cors/py.typed: -------------------------------------------------------------------------------- 1 | Marker 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E252, W503, W504 3 | max_line_length = 100 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | venv/ 3 | __pycache__/ 4 | Quart_CORS.egg-info/ 5 | .cache/ 6 | .tox/ 7 | TODO 8 | .mypy_cache/ 9 | .hypothesis/ 10 | docs/_build/ 11 | docs/reference/source/ 12 | .coverage 13 | .pytest_cache/ 14 | dist/ 15 | pdm.lock 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | permissions: {} 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | contents: read 15 | 16 | steps: 17 | - uses: pgjones/actions/build@dbbee601c084d000c4fc711d4b27cb306e15ead1 # v1 18 | 19 | pypi-publish: 20 | needs: ['build'] 21 | environment: 'publish' 22 | 23 | name: upload release to PyPI 24 | runs-on: ubuntu-latest 25 | permissions: 26 | # IMPORTANT: this permission is mandatory for trusted publishing 27 | id-token: write 28 | steps: 29 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 30 | 31 | - name: Publish package distributions to PyPI 32 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 33 | with: 34 | packages-dir: artifact/ 35 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = format,mypy,py310,py311,py312,py313,py314,pep8,package 3 | isolated_build = True 4 | 5 | [testenv] 6 | deps = 7 | pytest 8 | pytest-asyncio 9 | pytest-cov 10 | pytest-sugar 11 | commands = pytest --cov=quart_cors {posargs} 12 | 13 | [testenv:format] 14 | basepython = python3.14 15 | deps = 16 | black 17 | isort 18 | commands = 19 | black --check --diff src/quart_cors/ tests/ 20 | isort --check --diff src/quart_cors/ tests 21 | 22 | [testenv:pep8] 23 | basepython = python3.14 24 | deps = 25 | flake8 26 | pep8-naming 27 | flake8-print 28 | commands = flake8 src/quart_cors/ tests/ 29 | 30 | [testenv:mypy] 31 | basepython = python3.14 32 | deps = 33 | mypy 34 | pytest 35 | commands = 36 | mypy src/quart_cors/ tests/ 37 | 38 | [testenv:package] 39 | basepython = python3.14 40 | deps = 41 | pdm 42 | twine 43 | commands = 44 | pdm build 45 | twine check dist/* 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright P G Jones 2018. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | workflow_dispatch: 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | tox: 13 | name: ${{ matrix.name }} 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: read 18 | 19 | container: python:${{ matrix.python }} 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | include: 25 | - {name: '3.14', python: '3.14', tox: py314} 26 | - {name: '3.13', python: '3.13', tox: py313} 27 | - {name: '3.12', python: '3.12', tox: py312} 28 | - {name: '3.11', python: '3.11', tox: py311} 29 | - {name: '3.10', python: '3.10', tox: py310} 30 | - {name: 'format', python: '3.14', tox: format} 31 | - {name: 'mypy', python: '3.14', tox: mypy} 32 | - {name: 'pep8', python: '3.14', tox: pep8} 33 | - {name: 'package', python: '3.14', tox: package} 34 | 35 | steps: 36 | - uses: pgjones/actions/tox@dbbee601c084d000c4fc711d4b27cb306e15ead1 # v1 37 | with: 38 | environment: ${{ matrix.tox }} 39 | 40 | zizmor: 41 | name: Zizmor 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - uses: pgjones/actions/zizmor@dbbee601c084d000c4fc711d4b27cb306e15ead1 # v1 46 | -------------------------------------------------------------------------------- /tests/test_websocket.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from quart import Quart, websocket 3 | from quart.testing import WebsocketResponseError 4 | 5 | from quart_cors import cors, cors_exempt 6 | 7 | 8 | @pytest.fixture(name="websocket_cors_app") 9 | def _websocket_cors_app() -> Quart: 10 | app = Quart(__name__) 11 | 12 | cors(app, allow_origin=["https://quart.com"]) 13 | 14 | @app.websocket("/") 15 | async def ws() -> None: 16 | await websocket.send(b"a") 17 | 18 | @app.websocket("/exempt") 19 | @cors_exempt 20 | async def ws_exempt() -> None: 21 | await websocket.send(b"a") 22 | 23 | return app 24 | 25 | 26 | async def test_websocket_allowed(websocket_cors_app: Quart) -> None: 27 | test_client = websocket_cors_app.test_client() 28 | async with test_client.websocket( 29 | "/", headers={"Origin": "https://quart.com"} 30 | ) as test_websocket: 31 | data = await test_websocket.receive() 32 | assert data == b"a" # type: ignore 33 | 34 | 35 | async def test_websocket_blocked(websocket_cors_app: Quart) -> None: 36 | test_client = websocket_cors_app.test_client() 37 | try: 38 | async with test_client.websocket("/") as test_websocket: 39 | await test_websocket.send(b"a") 40 | except WebsocketResponseError as error: 41 | assert error.response.status_code == 400 42 | 43 | 44 | async def test_websocket_exempt(websocket_cors_app: Quart) -> None: 45 | test_client = websocket_cors_app.test_client() 46 | async with test_client.websocket("/exempt") as test_websocket: 47 | data = await test_websocket.receive() 48 | assert data == b"a" # type: ignore 49 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 0.8.0 2024-12-27 2 | ---------------- 3 | 4 | * Add the ability to control origin wildcard sending as a wildcard 5 | seen in the allow origin header can be considered dangerous by some. 6 | * Add ``cors_exempt`` to top level package import. 7 | * Improve the typing througout. 8 | * Support Python 3.13, and 3.12 drop Python 3.8 and 3.7. 9 | 10 | 0.7.0 2023-09-23 11 | ---------------- 12 | 13 | * Send Vary: Origin for non-CORS requests. 14 | 15 | 0.6.0 2023-01-21 16 | ---------------- 17 | 18 | * Add the ability to exempt routes/websockets from cors. 19 | * Ensure header name comparison is based on lowercased header names. 20 | * Much improve the typing, leading to more accurate type checking. 21 | * Officially support Python 3.10, and Python 3.11. 22 | * Switch to GitHub rather than GitLab. 23 | 24 | 0.5.0 2021-05-11 25 | ---------------- 26 | 27 | * Support Quart 0.15 as the minimum version. 28 | 29 | 0.4.0 2021-03-09 30 | ---------------- 31 | 32 | * Support Python 3.9. 33 | * Allow the allowed origin to be a regex pattern (or iterable 34 | thereof). 35 | * Bugfix crash when sending OPTIONS with missing 36 | Access-Control-Allow-Origin header. 37 | 38 | 0.3.0 2020-02-09 39 | ---------------- 40 | 41 | * Support Python 3.8. 42 | * Support Quart >= 0.11.1 - with this only a single origin (or 43 | wildcard) can be returned as the Access-Control-Allow-Origin header, 44 | as per the specification. 45 | 46 | 0.2.0 2019-08-02 47 | ---------------- 48 | 49 | * Move files to within a quart_cors folder to ensure the py.typed file 50 | is picked up. 51 | * Drop support for Python 3.6. 52 | * Add a websocket_cors function that checks the origin and will 53 | respond with 400 if not an allowed origin. 54 | 55 | 0.1.3 2019-04-22 56 | ---------------- 57 | 58 | * Add py.typed for PEP 561 compliance. 59 | 60 | 0.1.2 2019-01-29 61 | ---------------- 62 | 63 | * Bugfix allow all request_headers when allow_headers is set to "*". 64 | 65 | 0.1.1 2018-12-09 66 | ---------------- 67 | 68 | * Bumped minimum Quart version to 0.6.11 due to a bug in Quart. 69 | 70 | 0.1.0 2018-06-11 71 | ---------------- 72 | 73 | * Released initial alpha version. 74 | -------------------------------------------------------------------------------- /tests/test_simple_request.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from quart import Quart 3 | from werkzeug.datastructures import HeaderSet 4 | 5 | from quart_cors import route_cors 6 | 7 | 8 | @pytest.fixture(name="app", scope="function") 9 | def _app() -> Quart: 10 | app = Quart(__name__) 11 | 12 | @app.route("/") 13 | @route_cors() 14 | async def index() -> str: 15 | return "Hello" 16 | 17 | return app 18 | 19 | 20 | # These tests are based on https://www.w3.org/TR/cors section 6.1, and 21 | # follow the logic given. 22 | 23 | 24 | async def test_no_origin(app: Quart) -> None: 25 | test_client = app.test_client() 26 | response = await test_client.get("/") 27 | assert "Access-Control-Allow-Origin" not in response.headers 28 | 29 | 30 | @pytest.mark.parametrize("origin", ["http://notquart.com", "http://Quart.com"]) 31 | async def test_origin_doesnt_match(app: Quart, origin: str) -> None: 32 | test_client = app.test_client() 33 | app.config["QUART_CORS_ALLOW_ORIGIN"] = ["http://quart.com"] 34 | response = await test_client.get("/", headers={"Origin": origin}) 35 | assert "Access-Control-Allow-Origin" not in response.headers 36 | 37 | 38 | async def test_credentials_and_wildcard(app: Quart) -> None: 39 | test_client = app.test_client() 40 | app.config["QUART_CORS_ALLOW_CREDENTIALS"] = True 41 | response = await test_client.get("/", headers={"Origin": "http://quart.com"}) 42 | assert response.status_code == 500 43 | 44 | 45 | async def test_credentials(app: Quart) -> None: 46 | test_client = app.test_client() 47 | app.config["QUART_CORS_ALLOW_ORIGIN"] = ["http://quart.com"] 48 | app.config["QUART_CORS_ALLOW_CREDENTIALS"] = True 49 | response = await test_client.get("/", headers={"Origin": "http://quart.com"}) 50 | assert response.access_control_allow_origin == "http://quart.com" 51 | assert response.vary == HeaderSet(["Origin"]) 52 | assert response.access_control_allow_credentials 53 | 54 | 55 | async def test_expose_headers(app: Quart) -> None: 56 | test_client = app.test_client() 57 | app.config["QUART_CORS_EXPOSE_HEADERS"] = ["X-Special", "X-Other"] 58 | response = await test_client.get("/", headers={"Origin": "http://quart.com"}) 59 | assert response.access_control_allow_origin == "*" 60 | assert response.access_control_expose_headers == HeaderSet(["X-Special", "X-Other"]) 61 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "quart-cors" 3 | version = "0.8.0" 4 | description = "A Quart extension to provide Cross Origin Resource Sharing, access control, support" 5 | authors = [ 6 | {name = "pgjones", email = "philip.graham.jones@googlemail.com"}, 7 | ] 8 | classifiers = [ 9 | "Development Status :: 3 - Alpha", 10 | "Environment :: Web Environment", 11 | "Intended Audience :: Developers", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Programming Language :: Python :: 3.14", 21 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | ] 24 | include = ["src/quart_cors/py.typed"] 25 | license = {text = "MIT"} 26 | readme = "README.rst" 27 | repository = "https://github.com/pgjones/quart-cors/" 28 | dependencies = [ 29 | "quart >= 0.15", 30 | "typing_extensions; python_version < '3.11'", 31 | ] 32 | requires-python = ">=3.10" 33 | 34 | [tool.black] 35 | line-length = 100 36 | target-version = ["py310"] 37 | 38 | [tool.isort] 39 | combine_as_imports = true 40 | force_grid_wrap = 0 41 | include_trailing_comma = true 42 | known_first_party = "quart_cors, tests" 43 | line_length = 100 44 | multi_line_output = 3 45 | no_lines_before = "LOCALFOLDER" 46 | order_by_type = false 47 | reverse_relative = true 48 | 49 | [tool.mypy] 50 | allow_redefinition = true 51 | disallow_any_generics = false 52 | disallow_subclassing_any = true 53 | disallow_untyped_calls = false 54 | disallow_untyped_defs = true 55 | implicit_reexport = true 56 | no_implicit_optional = true 57 | show_error_codes = true 58 | strict = true 59 | strict_equality = true 60 | strict_optional = false 61 | warn_redundant_casts = true 62 | warn_return_any = false 63 | warn_unused_configs = true 64 | warn_unused_ignores = true 65 | 66 | [tool.pytest.ini_options] 67 | addopts = "--no-cov-on-fail --showlocals --strict-markers" 68 | asyncio_default_fixture_loop_scope = "function" 69 | asyncio_mode = "auto" 70 | testpaths = ["tests"] 71 | 72 | [build-system] 73 | requires = ["pdm-backend"] 74 | build-backend = "pdm.backend" 75 | -------------------------------------------------------------------------------- /tests/test_overrides.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from quart import Blueprint, Quart 3 | from werkzeug.datastructures import HeaderSet 4 | 5 | from quart_cors import cors, route_cors 6 | 7 | 8 | @pytest.fixture(name="app") 9 | def _app() -> Quart: 10 | app = Quart(__name__) 11 | app = cors(app, allow_origin="http://app.com") 12 | app.config["QUART_CORS_ALLOW_METHODS"] = ["GET", "POST"] 13 | 14 | blueprint = Blueprint("blue", __name__) 15 | blueprint = cors(blueprint, allow_origin=["http://blueprint.com"]) 16 | 17 | @app.route("/app") 18 | async def app_route() -> str: 19 | return "App" 20 | 21 | @app.route("/route") 22 | @route_cors(allow_origin=["http://route.com"]) 23 | async def route() -> str: 24 | return "Route" 25 | 26 | @blueprint.route("/blueprint") 27 | async def blueprint_() -> str: 28 | return "Blueprint" 29 | 30 | @blueprint.route("/blueprint_route") 31 | @route_cors(allow_origin=["http://blueprint.route.com"]) 32 | async def blueprint_route() -> str: 33 | return "Blueprint Route" 34 | 35 | app.register_blueprint(blueprint) 36 | 37 | return app 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "origin, path", 42 | [ 43 | ("http://app.com", "/app"), 44 | ("http://route.com", "/route"), 45 | ("http://blueprint.com", "/blueprint"), 46 | ("http://blueprint.route.com", "/blueprint_route"), 47 | ], 48 | ) 49 | async def test_match(app: Quart, origin: str, path: str) -> None: 50 | test_client = app.test_client() 51 | response = await test_client.options( 52 | path, headers={"Origin": origin, "Access-Control-Request-Method": "POST"} 53 | ) 54 | assert response.access_control_allow_origin == origin 55 | assert response.access_control_allow_methods == HeaderSet(["GET", "POST"]) 56 | 57 | 58 | @pytest.mark.parametrize( 59 | "origin, path", 60 | [ 61 | ("http://app.com", "/route"), 62 | ("http://route.com", "/app"), 63 | ("http://blueprint.com", "/blueprint_route"), 64 | ("http://blueprint.route.com", "/blueprint"), 65 | ], 66 | ) 67 | async def test_no_match(app: Quart, origin: str, path: str) -> None: 68 | test_client = app.test_client() 69 | response = await test_client.options( 70 | path, headers={"Origin": origin, "Access-Control-Request-Method": "POST"} 71 | ) 72 | assert "Access-Control-Allow-Origin" not in response.headers 73 | -------------------------------------------------------------------------------- /tests/test_preflight_request.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from quart import Quart 3 | from werkzeug.datastructures import HeaderSet 4 | 5 | from quart_cors import route_cors 6 | 7 | 8 | @pytest.fixture(name="app", scope="function") 9 | def _app() -> Quart: 10 | app = Quart(__name__) 11 | 12 | @app.route("/") 13 | @route_cors() 14 | async def index() -> str: 15 | return "Hello" 16 | 17 | return app 18 | 19 | 20 | # These tests are based on https://www.w3.org/TR/cors section 6.2, and 21 | # follow the logic given. 22 | 23 | 24 | async def test_no_origin(app: Quart) -> None: 25 | test_client = app.test_client() 26 | response = await test_client.options("/") 27 | assert "Access-Control-Allow-Origin" not in response.headers 28 | 29 | 30 | @pytest.mark.parametrize("origin", ["http://notquart.com", "http://Quart.com"]) 31 | async def test_origin_doesnt_match(app: Quart, origin: str) -> None: 32 | test_client = app.test_client() 33 | app.config["QUART_CORS_ALLOW_ORIGIN"] = ["http://quart.com"] 34 | response = await test_client.options("/", headers={"Origin": origin}) 35 | assert "Access-Control-Allow-Origin" not in response.headers 36 | 37 | 38 | async def test_request_method_doesnt_match(app: Quart) -> None: 39 | test_client = app.test_client() 40 | app.config["QUART_CORS_ALLOW_METHODS"] = ["GET", "POST"] 41 | response = await test_client.options( 42 | "/", headers={"Origin": "http://quart.com", "Access-Control-Request-Method": "DELETE"} 43 | ) 44 | assert response.access_control_allow_origin == "*" 45 | assert "Access-Control-Allow-Headers" not in response.headers 46 | 47 | 48 | async def test_request_method_match(app: Quart) -> None: 49 | test_client = app.test_client() 50 | app.config["QUART_CORS_ALLOW_METHODS"] = ["GET", "POST"] 51 | response = await test_client.options( 52 | "/", headers={"Origin": "http://quart.com", "Access-Control-Request-Method": "POST"} 53 | ) 54 | assert response.access_control_allow_origin == "*" 55 | assert response.access_control_allow_methods == HeaderSet(["GET", "POST"]) 56 | 57 | 58 | @pytest.mark.parametrize( 59 | "config_headers, request_headers, expected", 60 | [ 61 | (["X-Match", "X-Other"], "X-Match, X-No-Match", ["X-Match"]), 62 | (["X-match", "X-Other"], "X-Match, X-No-Match", ["x-match"]), 63 | ], 64 | ) 65 | async def test_request_headers( 66 | app: Quart, config_headers: list[str], request_headers: str, expected: list[str] 67 | ) -> None: 68 | test_client = app.test_client() 69 | app.config["QUART_CORS_ALLOW_HEADERS"] = config_headers 70 | response = await test_client.options( 71 | "/", 72 | headers={ 73 | "Origin": "http://quart.com", 74 | "Access-Control-Request-Method": "POST", 75 | "Access-Control-Request-Headers": request_headers, 76 | }, 77 | ) 78 | assert response.access_control_allow_origin == "*" 79 | assert response.access_control_allow_headers == HeaderSet(expected) 80 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import timedelta 3 | from re import Pattern 4 | 5 | import pytest 6 | from quart import Blueprint, Quart 7 | from werkzeug.datastructures import HeaderSet 8 | 9 | from quart_cors import cors, route_cors 10 | 11 | 12 | @pytest.fixture(name="route_cors_app") 13 | def _route_cors_app() -> Quart: 14 | app = Quart(__name__) 15 | 16 | @app.route("/") 17 | @route_cors(max_age=timedelta(seconds=5)) 18 | async def index() -> str: 19 | return "Hello" 20 | 21 | return app 22 | 23 | 24 | async def test_simple_cross_origin_request(route_cors_app: Quart) -> None: 25 | test_client = route_cors_app.test_client() 26 | response = await test_client.get("/", headers={"Origin": "https://quart.com"}) 27 | assert response.access_control_allow_origin == "*" 28 | 29 | 30 | async def test_preflight_request(route_cors_app: Quart) -> None: 31 | test_client = route_cors_app.test_client() 32 | response = await test_client.options( 33 | "/", headers={"Origin": "https://quart.com", "Access-Control-Request-Method": "DELETE"} 34 | ) 35 | assert response.access_control_allow_origin == "*" 36 | assert response.access_control_allow_methods == HeaderSet( 37 | ["GET", "HEAD", "POST", "OPTIONS", "PUT", "PATCH", "DELETE"] 38 | ) 39 | assert response.access_control_max_age == 5 40 | 41 | 42 | @pytest.mark.parametrize("kind", ("no-header", "empty-header")) 43 | async def test_bad_preflight_request(route_cors_app: Quart, kind: str) -> None: 44 | test_client = route_cors_app.test_client() 45 | # Missing Access-Control-Request-Method header 46 | headers = {"Origin": "https://quart.com"} 47 | if kind == "empty-header": 48 | headers["Access-Control-Request-Method"] = "" 49 | response = await test_client.options("/", headers=headers) 50 | assert response.access_control_allow_origin == "*" 51 | assert response.access_control_allow_methods is None 52 | assert response.access_control_max_age is None 53 | 54 | 55 | async def test_app_cors() -> None: 56 | app = Quart(__name__) 57 | 58 | @app.route("/") 59 | async def index() -> str: 60 | return "Hello" 61 | 62 | app = cors(app) 63 | 64 | test_client = app.test_client() 65 | response = await test_client.get("/", headers={"Origin": "https://quart.com"}) 66 | assert response.access_control_allow_origin == "*" 67 | 68 | 69 | async def test_blueprint_cors() -> None: 70 | app = Quart(__name__) 71 | 72 | blueprint = Blueprint("name", __name__) 73 | 74 | blueprint = cors(blueprint) 75 | 76 | @blueprint.route("/") 77 | async def index() -> str: 78 | return "Hello" 79 | 80 | app.register_blueprint(blueprint) 81 | 82 | test_client = app.test_client() 83 | response = await test_client.get("/", headers={"Origin": "https://quart.com"}) 84 | assert response.access_control_allow_origin == "*" 85 | 86 | 87 | @pytest.mark.parametrize( 88 | "allowed_origin, expected", 89 | [ 90 | ("*", "*"), 91 | ("https://quart.com", "https://quart.com"), 92 | (re.compile(r"https:\/\/.*\.?quart\.com"), "https://quart.com"), 93 | ], 94 | ) 95 | async def test_regex_matching(allowed_origin: Pattern | str, expected: str) -> None: 96 | app = Quart(__name__) 97 | app.config["QUART_CORS_ALLOW_ORIGIN"] = [allowed_origin] 98 | 99 | @app.route("/") 100 | async def index() -> str: 101 | return "Hello" 102 | 103 | app = cors(app) 104 | 105 | test_client = app.test_client() 106 | response = await test_client.get("/", headers={"Origin": "https://quart.com"}) 107 | assert response.access_control_allow_origin == expected 108 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Quart-CORS 2 | ========== 3 | 4 | |Build Status| |pypi| |python| |license| 5 | 6 | Quart-CORS is an extension for `Quart 7 | `_ to enable and control `Cross 8 | Origin Resource Sharing `_, CORS (also 9 | known as access control). 10 | 11 | CORS is required to share resources in browsers due to the `Same 12 | Origin Policy `_ 13 | which prevents resources being used from a different origin. An origin 14 | in this case is defined as the scheme, host and port combined and a 15 | resource corresponds to a path. 16 | 17 | In practice the Same Origin Policy means that a browser visiting 18 | ``http://quart.com`` will prevent the response of ``GET 19 | http://api.com`` being read. It will also prevent requests such as 20 | ``POST http://api.com``. Note that CORS applies to browser initiated 21 | requests, non-browser clients such as ``requests`` are not subject to 22 | CORS restrictions. 23 | 24 | CORS allows a server to indicate to a browser that certain resources 25 | can be used, contrary to the Same Origin Policy. It does so via 26 | access-control headers that inform the browser how the resource can be 27 | used. For GET requests these headers are sent in the response. For 28 | non-GET requests the browser must ask the server for the 29 | access-control headers before sending the actual request, it does so 30 | via a preflight OPTIONS request. 31 | 32 | The Same Origin Policy does not apply to WebSockets, and hence there 33 | is no need for CORS. Instead the server alone is responsible for 34 | deciding if the WebSocket is allowed and it should do so by inspecting 35 | the WebSocket-request origin header. 36 | 37 | Simple (GET) requests should return CORS headers specifying the 38 | origins that are allowed to use the resource (response). This can be 39 | any origin, ``*`` (wildcard), or a list of specific origins. The 40 | response should also include a CORS header specifying whether 41 | response-credentials e.g. cookies can be used. Note that if credential 42 | sharing is allowed the allowed origins must be specific and not a 43 | wildcard. 44 | 45 | Preflight requests should return CORS headers specifying the origins 46 | allowed to use the resource, the methods and headers allowed to be 47 | sent in a request to the resource, whether response credentials can be 48 | used, and finally which response headers can be used. 49 | 50 | Note that certain actions are allowed in the Same Origin Policy such 51 | as embedding e.g. ```` and simple 52 | POSTs. For the purposes of this readme though these complications are 53 | ignored. 54 | 55 | The CORS access control response headers are, 56 | 57 | ================================ =========================================================== 58 | Header name Meaning 59 | -------------------------------- ----------------------------------------------------------- 60 | Access-Control-Allow-Origin Origins that are allowed to use the resource. 61 | Access-Control-Allow-Credentials Can credentials be shared. 62 | Access-Control-Allow-Methods Methods that may be used in requests to the resource. 63 | Access-Control-Allow-Headers Headers that may be sent in requests to the resource. 64 | Access-Control-Expose-Headers Headers that may be read in the response from the resource. 65 | Access-Control-Max-Age Maximum age to cache the CORS headers for the resource. 66 | ================================ =========================================================== 67 | 68 | Quart-CORS uses the same naming (without the Access-Control prefix) 69 | for it's arguments and settings when they relate to the same meaning. 70 | 71 | 72 | Installation 73 | ------------ 74 | 75 | Quart-CORS can be installed using pip or your favorite python package manager: 76 | 77 | .. code-block:: console 78 | 79 | pip install quart-cors 80 | 81 | 82 | Usage 83 | ----- 84 | 85 | To add CORS access control headers to all of the routes in the 86 | application, simply apply the ``cors`` function to the application, or 87 | to a specific blueprint, 88 | 89 | .. code-block:: python 90 | 91 | from quart_cors import cors 92 | 93 | app = Quart(__name__) 94 | app = cors(app, **settings) 95 | 96 | blueprint = Blueprint(__name__) 97 | blueprint = cors(blueprint, **settings) 98 | 99 | alternatively if you wish to add CORS selectively by resource, apply 100 | the ``route_cors`` function to a route, or the ``websocket_cors`` 101 | function to a WebSocket, 102 | 103 | .. code-block:: python 104 | 105 | from quart_cors import route_cors 106 | 107 | @app.route('/') 108 | @route_cors(**settings) 109 | async def handler(): 110 | ... 111 | 112 | @app.websocket('/') 113 | @websocket_cors(allow_origin=...) 114 | async def handler(): 115 | ... 116 | 117 | The ``settings`` are these arguments, 118 | 119 | ==================== ==================================================== 120 | Argument type 121 | -------------------- ---------------------------------------------------- 122 | allow_origin Union[Set[Union[Pattern, str]], Union[Pattern, str]] 123 | allow_credentials bool 124 | allow_methods Union[Set[str], str] 125 | allow_headers Union[Set[str], str] 126 | expose_headers Union[Set[str], str] 127 | max_age Union[int, flot, timedelta] 128 | send_origin_wildcard bool 129 | ==================== ==================================================== 130 | 131 | which correspond to the CORS headers noted above (bar 132 | ``send_origin_wildcard``). The ``send_origin_wildcard`` argument 133 | specifies whether to send a wildcard or echo the request origin in the 134 | allow origin header. Note that all settings are optional and defaults 135 | can be specified in the application configuration, 136 | 137 | =============================== ======================== 138 | Configuration key type 139 | ------------------------------- ------------------------ 140 | QUART_CORS_ALLOW_ORIGIN Set[Union[Pattern, str]] 141 | QUART_CORS_ALLOW_CREDENTIALS bool 142 | QUART_CORS_ALLOW_METHODS Set[str] 143 | QUART_CORS_ALLOW_HEADERS Set[str] 144 | QUART_CORS_EXPOSE_HEADERS Set[str] 145 | QUART_CORS_MAX_AGE float 146 | QUART_CORS_SEND_ORIGIN_WILDCARD bool 147 | =============================== ======================== 148 | 149 | The ``websocket_cors`` decorator only takes ``allow_origin`` and 150 | ``send_origin_wildcard`` arguments which defines the origins that are 151 | allowed to use the WebSocket and whether a wildcard should be sent in 152 | the allow origin header. A WebSocket request from a disallowed origin 153 | will be responded to with a 400 response. 154 | 155 | The ``allow_origin`` origins should be the origin only (no path, query 156 | strings or fragments) i.e. ``https://quart.com`` not 157 | ``https://quart.com/``. 158 | 159 | The ``cors_exempt`` decorator can be used in conjunction with ``cors`` 160 | to exempt a websocket handler or view function from cors. You can find 161 | a usage example in "Simple examples" section down below. 162 | 163 | Simple examples 164 | ~~~~~~~~~~~~~~~ 165 | 166 | To allow an app to be used from any origin (not recommended as it is 167 | too permissive), 168 | 169 | .. code-block:: python 170 | 171 | app = Quart(__name__) 172 | app = cors(app, allow_origin="*") 173 | 174 | To allow a route or WebSocket to be used from another specific domain, 175 | ``https://quart.com``, 176 | 177 | .. code-block:: python 178 | 179 | @app.route('/') 180 | @route_cors(allow_origin="https://quart.com") 181 | async def handler(): 182 | ... 183 | 184 | @app.websocket('/') 185 | @websocket_cors(allow_origin="https://quart.com") 186 | async def handler(): 187 | ... 188 | 189 | To allow a route or WebSocket to be used from any subdomain (but not 190 | the domain itself) of ``quart.com``, 191 | 192 | .. code-block:: python 193 | 194 | @app.route('/') 195 | @route_cors(allow_origin=re.compile(r"https:\/\/.*\.quart\.com")) 196 | async def handler(): 197 | ... 198 | 199 | @app.websocket('/') 200 | @websocket_cors(allow_origin=re.compile(r"https:\/\/.*\.quart\.com")) 201 | async def handler(): 202 | ... 203 | 204 | To exempt a WebSocket handler from CORS, 205 | 206 | .. code-block:: python 207 | 208 | @app.websocket('/') 209 | @cors_exempt 210 | async def handler(): 211 | ... 212 | 213 | To allow a JSON POST request to an API route, from ``https://quart.com``, 214 | 215 | .. code-block:: python 216 | 217 | @app.route('/', methods=["POST"]) 218 | @route_cors( 219 | allow_headers=["content-type"], 220 | allow_methods=["POST"], 221 | allow_origin=["https://quart.com"], 222 | ) 223 | async def handler(): 224 | data = await request.get_json() 225 | ... 226 | 227 | Contributing 228 | ------------ 229 | 230 | Quart-CORS is developed on `GitHub 231 | `_. You are very welcome to 232 | open `issues `_ or 233 | propose `merge requests 234 | `_. 235 | 236 | Testing 237 | ~~~~~~~ 238 | 239 | The best way to test Quart-CORS is with Tox, 240 | 241 | .. code-block:: console 242 | 243 | $ pip install tox 244 | $ tox 245 | 246 | this will check the code style and run the tests. 247 | 248 | Help 249 | ---- 250 | 251 | This README is the best place to start, after that try opening an 252 | `issue `_. 253 | 254 | 255 | .. |Build Status| image:: https://github.com/pgjones/quart-cors/actions/workflows/ci.yml/badge.svg 256 | :target: https://github.com/pgjones/quart-cors/commits/main 257 | 258 | .. |pypi| image:: https://img.shields.io/pypi/v/quart-cors.svg 259 | :target: https://pypi.python.org/pypi/Quart-CORS/ 260 | 261 | .. |python| image:: https://img.shields.io/pypi/pyversions/quart-cors.svg 262 | :target: https://pypi.python.org/pypi/Quart-CORS/ 263 | 264 | .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg 265 | :target: https://github.com/pgjones/quart-cors/blob/main/LICENSE 266 | -------------------------------------------------------------------------------- /src/quart_cors/__init__.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable, Iterable 2 | from datetime import timedelta 3 | from functools import partial, wraps 4 | from re import Pattern 5 | from typing import Any, cast, ParamSpec, TypeVar 6 | 7 | from quart import ( 8 | abort, 9 | Blueprint, 10 | current_app, 11 | make_response, 12 | Quart, 13 | request, 14 | Response, 15 | ResponseReturnValue, 16 | websocket, 17 | ) 18 | from quart.typing import RouteCallable, WebsocketCallable 19 | from werkzeug.datastructures import HeaderSet 20 | 21 | __all__ = ("cors", "route_cors", "websocket_cors", "cors_exempt") 22 | 23 | OriginType = Pattern | str 24 | 25 | DEFAULTS = { 26 | "QUART_CORS_ALLOW_CREDENTIALS": False, 27 | "QUART_CORS_ALLOW_HEADERS": ["*"], 28 | "QUART_CORS_ALLOW_METHODS": ["GET", "HEAD", "POST", "OPTIONS", "PUT", "PATCH", "DELETE"], 29 | "QUART_CORS_ALLOW_ORIGIN": ["*"], 30 | "QUART_CORS_EXPOSE_HEADERS": [""], 31 | "QUART_CORS_MAX_AGE": None, 32 | "QUART_CORS_SEND_ORIGIN_WILDCARD": True, 33 | } 34 | 35 | QUART_CORS_EXEMPT_ATTRIBUTE = "_quart_cors_exempt" 36 | 37 | P = ParamSpec("P") 38 | 39 | 40 | def route_cors( 41 | *, 42 | allow_credentials: bool | None = None, 43 | allow_headers: Iterable[str] | None = None, 44 | allow_methods: Iterable[str] | None = None, 45 | allow_origin: OriginType | Iterable[OriginType] | None = None, 46 | expose_headers: Iterable[str] | None = None, 47 | max_age: timedelta | float | str | None = None, 48 | send_origin_wildcard: bool | None = None, 49 | provide_automatic_options: bool = True, 50 | ) -> Callable[ 51 | [Callable[P, ResponseReturnValue] | Callable[P, Awaitable[ResponseReturnValue]]], 52 | Callable[P, Awaitable[Response]], 53 | ]: 54 | """A decorator to add the CORS access control headers. 55 | 56 | This should be used to wrap a route handler (or view function) to 57 | apply CORS headers to the route. Note that it is important that 58 | this decorator be wrapped by the route decorator and not vice, 59 | versa, as below. 60 | 61 | .. code-block:: python 62 | 63 | @app.route('/') 64 | @route_cors() 65 | async def index(): 66 | ... 67 | 68 | Arguments: 69 | allow_credentials: If set the allow credentials header will 70 | be set, thereby allowing credentials to be shared. Note 71 | that this does not work with a wildcard origin 72 | argument. 73 | allow_headers: A list of headers, a regex or single header 74 | name, a cross origin request is allowed to access. 75 | allow_methods: The methods (list) or method (str) that a cross 76 | origin request can use. 77 | allow_origin: The origins from which cross origin requests are 78 | accepted. This is either a list of re.complied regex or 79 | strings, or a single re.compiled regex or string, or the 80 | wildward string, `*`. Note the full domain including scheme 81 | is required. 82 | expose_headers: The additional headers (list) or header (str) 83 | to expose to the client of a cross origin request. 84 | max_age: The maximum time the response can be cached by the 85 | client. 86 | send_origin_wildcard: Send wildcard, "*", as the allow origin 87 | were appropriate (or echo the request origin). 88 | provide_automatic_options: If set the automatic OPTIONS 89 | response created by Quart will be overwriten by one 90 | created by Quart-CORS. 91 | 92 | """ 93 | 94 | def decorator( 95 | func: Callable[P, ResponseReturnValue] | Callable[P, Awaitable[ResponseReturnValue]], 96 | ) -> Callable[P, Awaitable[Response]]: 97 | if provide_automatic_options: 98 | func.required_methods = getattr(func, "required_methods", set()) # type: ignore 99 | func.required_methods.add("OPTIONS") # type: ignore 100 | func.provide_automatic_options = False # type: ignore 101 | 102 | @wraps(func) 103 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Response: 104 | nonlocal allow_credentials, allow_headers, allow_methods, allow_origin, expose_headers 105 | nonlocal max_age, send_origin_wildcard 106 | 107 | method = request.method 108 | 109 | if provide_automatic_options and method == "OPTIONS": 110 | response = await current_app.make_default_options_response() 111 | else: 112 | response = cast( 113 | Response, 114 | await make_response(await current_app.ensure_async(func)(*args, **kwargs)), 115 | ) 116 | 117 | allow_credentials = allow_credentials or _get_config_or_default( 118 | "QUART_CORS_ALLOW_CREDENTIALS" 119 | ) 120 | allow_headers = _sanitise_header_set(allow_headers, "QUART_CORS_ALLOW_HEADERS") 121 | allow_methods = _sanitise_header_set(allow_methods, "QUART_CORS_ALLOW_METHODS") 122 | allow_origin = _sanitise_origin_set(allow_origin, "QUART_CORS_ALLOW_ORIGIN") 123 | expose_headers = _sanitise_header_set(expose_headers, "QUART_CORS_EXPOSE_HEADERS") 124 | max_age = _sanitise_max_age(max_age, "QUART_CORS_MAX_AGE") 125 | send_origin_wildcard = send_origin_wildcard or _get_config_or_default( 126 | "QUART_CORS_SEND_ORIGIN_WILDCARD" 127 | ) 128 | response = _apply_cors( 129 | request.origin, 130 | request.access_control_request_headers, 131 | request.access_control_request_method, 132 | method, 133 | response, 134 | allow_credentials=allow_credentials, 135 | allow_headers=allow_headers, 136 | allow_methods=allow_methods, 137 | allow_origin=allow_origin, 138 | expose_headers=expose_headers, 139 | max_age=max_age, 140 | send_origin_wildcard=send_origin_wildcard, 141 | ) 142 | return response 143 | 144 | return wrapper 145 | 146 | return decorator 147 | 148 | 149 | V = TypeVar("V", bound=ResponseReturnValue | None) 150 | 151 | 152 | def websocket_cors( 153 | *, 154 | allow_origin: OriginType | Iterable[OriginType] | None = None, 155 | send_origin_wildcard: bool | None = None, 156 | ) -> Callable[[Callable[P, V | Awaitable[V]]], Callable[P, Awaitable[V]]]: 157 | """A decorator to control CORS websocket requests. 158 | 159 | This should be used to wrap a websocket handler (or view function) 160 | to control CORS access to the websocket. Note that it is important 161 | that this decorator be wrapped by the websocket decorator and not 162 | vice, versa, as below. 163 | 164 | .. code-block:: python 165 | 166 | @app.websocket('/') 167 | @websocket_cors() 168 | async def index(): 169 | ... 170 | 171 | Arguments: 172 | allow_origin: The origins from which cross origin requests are 173 | accepted. This is either a list of re.complied regex or 174 | strings, or a single re.compiled regex or string, or the 175 | wildward string, `*`. Note the full domain including scheme 176 | is required. 177 | send_origin_wildcard: Send wildcard, "*", as the allow origin 178 | were appropriate (or echo the request origin). 179 | 180 | """ 181 | 182 | def decorator(func: Callable[P, V | Awaitable[V]]) -> Callable[P, Awaitable[V]]: 183 | @wraps(func) 184 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> V: 185 | # Will abort if origin is invalid 186 | _apply_websocket_cors( 187 | allow_origin=allow_origin, send_origin_wildcard=send_origin_wildcard 188 | ) 189 | 190 | return await current_app.ensure_async(func)(*args, **kwargs) # type: ignore 191 | 192 | return wrapper 193 | 194 | return decorator 195 | 196 | 197 | U = TypeVar("U", bound=RouteCallable | WebsocketCallable) 198 | 199 | 200 | def cors_exempt(func: U) -> U: 201 | """A decorator to exempt a websocket handler or view function from CORS control. 202 | 203 | This can be used in conjunction with the `cors` function to mark a 204 | single websocket handler or view function as exempt from CORS 205 | i.e. don't add CORS headers to responses and don't check the 206 | origin. 207 | 208 | .. code-block:: python 209 | 210 | @app.websocket('/') 211 | @cors_exempt 212 | async def index(): 213 | ... 214 | """ 215 | setattr(func, QUART_CORS_EXEMPT_ATTRIBUTE, True) 216 | return func 217 | 218 | 219 | T = TypeVar("T", bound=Blueprint | Quart) 220 | 221 | 222 | def cors( 223 | app_or_blueprint: T, 224 | *, 225 | allow_credentials: bool | None = None, 226 | allow_headers: Iterable[str] | None = None, 227 | allow_methods: Iterable[str] | None = None, 228 | allow_origin: OriginType | Iterable[OriginType] | None = None, 229 | expose_headers: Iterable[str] | None = None, 230 | max_age: timedelta | float | str | None = None, 231 | send_origin_wildcard: bool | None = None, 232 | ) -> T: 233 | """Apply the CORS access control headers to all routes. 234 | 235 | This should be used on a Quart (app) instance or a Blueprint 236 | instance to apply CORS headers to the associated routes. 237 | 238 | .. code-block:: python 239 | 240 | app = cors(app) 241 | blueprint = cors(blueprint) 242 | 243 | Arguments: 244 | allow_credentials: If set the allow credentials header will 245 | be set, thereby allowing credentials to be shared. Note 246 | that this does not work with a wildcard origin 247 | argument. 248 | allow_headers: A list of headers, a regex or single header 249 | name, a cross origin request is allowed to access. 250 | allow_methods: The methods (list) or method (str) that a cross 251 | origin request can use. 252 | allow_origin: The origins from which cross origin requests are 253 | accepted. This is either a list of re.complied regex or 254 | strings, or a single re.compiled regex or string, or the 255 | wildward string, `*`. Note the full domain including scheme 256 | is required. 257 | expose_headers: The additional headers (list) or header (str) 258 | to expose to the client of a cross origin request. 259 | max_age: The maximum time the response can be cached by the 260 | client. 261 | send_origin_wildcard: Send wildcard, "*", as the allow origin 262 | were appropriate (or echo the request origin). 263 | 264 | """ 265 | app_or_blueprint.after_request( 266 | partial( 267 | _after_request, 268 | allow_credentials=allow_credentials, 269 | allow_headers=allow_headers, 270 | allow_methods=allow_methods, 271 | allow_origin=allow_origin, 272 | expose_headers=expose_headers, 273 | max_age=max_age, 274 | send_origin_wildcard=send_origin_wildcard, 275 | ) 276 | ) 277 | app_or_blueprint.before_websocket( 278 | partial( 279 | _before_websocket, allow_origin=allow_origin, send_origin_wildcard=send_origin_wildcard 280 | ) 281 | ) 282 | return app_or_blueprint 283 | 284 | 285 | async def _before_websocket( 286 | *, 287 | allow_origin: OriginType | Iterable[OriginType] | None = None, 288 | send_origin_wildcard: bool | None = None, 289 | ) -> None: 290 | view_func = current_app.view_functions.get(websocket.endpoint) 291 | if not getattr(view_func, QUART_CORS_EXEMPT_ATTRIBUTE, False): 292 | return _apply_websocket_cors( 293 | allow_origin=allow_origin, send_origin_wildcard=send_origin_wildcard 294 | ) 295 | 296 | 297 | async def _after_request( 298 | response: Response | None, 299 | *, 300 | allow_credentials: bool | None = None, 301 | allow_headers: Iterable[str] | None = None, 302 | allow_methods: Iterable[str] | None = None, 303 | allow_origin: OriginType | Iterable[OriginType] | None = None, 304 | expose_headers: Iterable[str] | None = None, 305 | max_age: timedelta | float | str | None = None, 306 | send_origin_wildcard: bool | None = None, 307 | ) -> Response | None: 308 | allow_credentials = allow_credentials or _get_config_or_default("QUART_CORS_ALLOW_CREDENTIALS") 309 | allow_headers = _sanitise_header_set(allow_headers, "QUART_CORS_ALLOW_HEADERS") 310 | allow_methods = _sanitise_header_set(allow_methods, "QUART_CORS_ALLOW_METHODS") 311 | allow_origin = _sanitise_origin_set(allow_origin, "QUART_CORS_ALLOW_ORIGIN") 312 | expose_headers = _sanitise_header_set(expose_headers, "QUART_CORS_EXPOSE_HEADERS") 313 | max_age = _sanitise_max_age(max_age, "QUART_CORS_MAX_AGE") 314 | send_origin_wildcard = send_origin_wildcard or _get_config_or_default( 315 | "QUART_CORS_SEND_ORIGIN_WILDCARD" 316 | ) 317 | 318 | method = request.method 319 | 320 | view_func = current_app.view_functions.get(request.endpoint) 321 | if not getattr(view_func, QUART_CORS_EXEMPT_ATTRIBUTE, False): 322 | return _apply_cors( 323 | request.origin, 324 | request.access_control_request_headers, 325 | request.access_control_request_method, 326 | method, 327 | response, 328 | allow_credentials=allow_credentials, 329 | allow_headers=allow_headers, 330 | allow_methods=allow_methods, 331 | allow_origin=allow_origin, 332 | expose_headers=expose_headers, 333 | max_age=max_age, 334 | send_origin_wildcard=send_origin_wildcard, 335 | ) 336 | else: 337 | return response 338 | 339 | 340 | def _apply_cors( 341 | request_origin: str | None, 342 | request_headers: HeaderSet | None, 343 | request_method: str | None, 344 | method: str, 345 | response: Response, 346 | *, 347 | allow_credentials: bool, 348 | allow_headers: HeaderSet, 349 | allow_methods: HeaderSet, 350 | allow_origin: set[OriginType], 351 | expose_headers: HeaderSet, 352 | max_age: int | None, 353 | send_origin_wildcard: bool, 354 | ) -> Response: 355 | # Logic follows https://www.w3.org/TR/cors/ 356 | if "*" in allow_origin and allow_credentials: 357 | raise ValueError("Cannot allow credentials with wildcard allowed origins") 358 | 359 | if getattr(response, "_QUART_CORS_APPLIED", False): 360 | return response 361 | 362 | origin = _get_origin_if_valid(request_origin, allow_origin, send_origin_wildcard) 363 | if origin is not None: 364 | response.access_control_allow_origin = origin 365 | response.access_control_allow_credentials = allow_credentials 366 | response.access_control_expose_headers = expose_headers 367 | if ( 368 | method == "OPTIONS" 369 | and request_method 370 | and (request_method in allow_methods or "*" in allow_methods) 371 | ): 372 | if request_headers is None: 373 | request_headers = HeaderSet() 374 | if "*" in allow_headers: 375 | response.access_control_allow_headers = request_headers 376 | else: 377 | response.access_control_allow_headers = HeaderSet( 378 | allow_headers.as_set().intersection(request_headers.as_set()) 379 | ) 380 | response.access_control_allow_methods = allow_methods 381 | if max_age is not None: 382 | response.access_control_max_age = max_age 383 | if origin is None or "*" not in origin: 384 | response.vary.add("Origin") 385 | setattr(response, "_QUART_CORS_APPLIED", True) 386 | return response 387 | 388 | 389 | def _apply_websocket_cors( 390 | *, 391 | allow_origin: OriginType | Iterable[OriginType] | None = None, 392 | send_origin_wildcard: bool | None = None, 393 | ) -> None: 394 | allow_origin = _sanitise_origin_set(allow_origin, "QUART_CORS_ALLOW_ORIGIN") 395 | send_origin_wildcard = send_origin_wildcard or _get_config_or_default( 396 | "QUART_CORS_SEND_ORIGIN_WILDCARD" 397 | ) 398 | origin = _get_origin_if_valid(websocket.origin, allow_origin, send_origin_wildcard) 399 | if origin is None: 400 | abort(400) 401 | 402 | 403 | def _sanitise_origin_set( 404 | value: OriginType | Iterable[OriginType] | None, config_key: str 405 | ) -> set[OriginType]: 406 | if value is None: 407 | value = _get_config_or_default(config_key) 408 | elif isinstance(value, (Pattern, str)): 409 | value = [value] 410 | return set(value) # type: ignore 411 | 412 | 413 | def _sanitise_header_set(value: str | Iterable[str] | None, config_key: str) -> HeaderSet: 414 | if value is None: 415 | value = _get_config_or_default(config_key) 416 | elif isinstance(value, str): 417 | value = [value] 418 | return HeaderSet(value) 419 | 420 | 421 | def _sanitise_max_age(value: timedelta | float | str | None, config_key: str) -> int: 422 | if value is None: 423 | value = _get_config_or_default(config_key) 424 | elif isinstance(value, timedelta): 425 | value = value.total_seconds() 426 | if value is not None: 427 | return int(value) # type: ignore 428 | return None 429 | 430 | 431 | def _get_config_or_default(config_key: str) -> Any: 432 | return current_app.config.get(config_key, DEFAULTS[config_key]) 433 | 434 | 435 | def _get_origin_if_valid( 436 | origin: str | None, allow_origin: set[OriginType], send_wildcard: bool 437 | ) -> str | None: 438 | if origin is None or origin == "": 439 | return None 440 | 441 | for allowed in allow_origin: 442 | if allowed == "*": 443 | if send_wildcard: 444 | return "*" 445 | else: 446 | return origin 447 | if isinstance(allowed, Pattern) and allowed.match(origin): 448 | return origin 449 | elif origin == allowed: 450 | return origin 451 | 452 | return None 453 | --------------------------------------------------------------------------------