PK˲EH+B5g:g:aiohttp_cors/cors_config.py# Copyright 2015 Vladimir Rutsky # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """CORS configuration container class definition. """ import asyncio import collections from typing import Mapping, Union, Any from aiohttp import hdrs, web from .urldispatcher_router_adapter import UrlDispatcherRouterAdapter from .abc import AbstractRouterAdapter from ._log import logger as _logger from .resource_options import ResourceOptions __all__ = ( "CorsConfig", ) # Positive response to Access-Control-Allow-Credentials _TRUE = "true" # CORS simple response headers: # _SIMPLE_RESPONSE_HEADERS = frozenset([ hdrs.CACHE_CONTROL, hdrs.CONTENT_LANGUAGE, hdrs.CONTENT_TYPE, hdrs.EXPIRES, hdrs.LAST_MODIFIED, hdrs.PRAGMA ]) def _parse_config_options( config: Mapping[str, Union[ResourceOptions, Mapping[str, Any]]]=None): """Parse CORS configuration (default or per-route) :param config: Mapping from Origin to Resource configuration (allowed headers etc) defined either as mapping or `ResourceOptions` instance. Raises `ValueError` if configuration is not correct. """ if config is None: return {} if not isinstance(config, collections.abc.Mapping): raise ValueError( "Config must be mapping, got '{}'".format(config)) parsed = {} options_keys = { "allow_credentials", "expose_headers", "allow_headers", "max_age" } for origin, options in config.items(): # TODO: check that all origins are properly formatted. # This is not a security issue, since origin is compared as strings. if not isinstance(origin, str): raise ValueError( "Origin must be string, got '{}'".format(origin)) if isinstance(options, ResourceOptions): resource_options = options else: if not isinstance(options, collections.abc.Mapping): raise ValueError( "Origin options must be either " "aiohttp_cors.ResourceOptions instance or mapping, " "got '{}'".format(options)) unexpected_args = frozenset(options.keys()) - options_keys if unexpected_args: raise ValueError( "Unexpected keywords in resource options: {}".format( # pylint: disable=bad-builtin ",".join(map(str, unexpected_args)))) resource_options = ResourceOptions(**options) parsed[origin] = resource_options return parsed class CorsConfig: """CORS configuration instance. The instance holds default CORS parameters and per-route options specified in `add()` method. Each `aiohttp.web.Application` can have exactly one instance of this class. """ def __init__(self, app: web.Application, *, defaults: Mapping[str, Union[ResourceOptions, Mapping[str, Any]]]=None, router_adapter: AbstractRouterAdapter=None): """Construct CORS configuration. :param app: Application for which CORS configuration is built. :param defaults: Default CORS settings for origins. :param router_adapter: Router adapter. Required if application uses non-default router. """ self._app = app self._router_adapter = router_adapter if self._router_adapter is None: if isinstance(self._app.router, web.UrlDispatcher): self._router_adapter = UrlDispatcherRouterAdapter( self._app.router) else: raise RuntimeError( "Router adapter not specified. " "Routers other than aiohttp.web.UrlDispatcher requires" "custom router adapter.") self._default_config = _parse_config_options(defaults) self._route_config = {} # Preflight handlers stored in order in which routes were configured # with CORS (order of "cors.add(...)" calls). self._preflight_route_settings = collections.OrderedDict() self._app.on_response_prepare.append(self._on_response_prepare) def add(self, route, config: Mapping[str, Union[ResourceOptions, Mapping[str, Any]]]=None): """Enable CORS for specific route. CORS is enable **only** for routes added with this method. :param route: Route for which CORS will be enabled. :param config: CORS options for the route. :return: ``route``. """ if route in self._preflight_route_settings: _logger.warning( "Trying to configure CORS for internal CORS handler route. " "Ignoring:\n" "{!r}".format(route)) return route if config is None and not self._default_config: _logger.warning( "No allowed origins configured for route %s, " "resource will not be shared with other origins. " "Setup either default origins in " "aiohttp_cors.setup(app, defaults=...) or" "explicitly specify origins when adding route to CORS.", route) parsed_config = _parse_config_options(config) defaulted_config = collections.ChainMap( parsed_config, self._default_config) route_methods = frozenset(self._router_adapter.route_methods(route)) # TODO: Limited handling of CORS on OPTIONS may be useful? if {hdrs.METH_ANY, hdrs.METH_OPTIONS}.intersection(route_methods): raise ValueError( "CORS can't be enabled on route that handles OPTIONS " "request:\n" "{!r}".format(route)) assert route not in self._route_config self._route_config[route] = defaulted_config # Add preflight request handler preflight_route = self._router_adapter.add_options_method_handler( route, self._preflight_handler) assert preflight_route not in self._preflight_route_settings self._preflight_route_settings[preflight_route] = \ (defaulted_config, route_methods) return route @asyncio.coroutine def _on_response_prepare(self, request: web.Request, response: web.StreamResponse): """(Potentially) simple CORS request response processor. If request is done on CORS-enabled route, process request parameters and set appropriate CORS response headers. """ route = request.match_info.route config = self._route_config.get(route) if config is None: return # Handle according to part 6.1 of the CORS specification. origin = request.headers.get(hdrs.ORIGIN) if origin is None: # Terminate CORS according to CORS 6.1.1. return options = config.get(origin, config.get("*")) if options is None: # Terminate CORS according to CORS 6.1.2. return assert hdrs.ACCESS_CONTROL_ALLOW_ORIGIN not in response.headers assert hdrs.ACCESS_CONTROL_ALLOW_CREDENTIALS not in response.headers assert hdrs.ACCESS_CONTROL_EXPOSE_HEADERS not in response.headers # Process according to CORS 6.1.4. # Set exposed headers (server headers exposed to client) before # setting any other headers. if options.expose_headers == "*": # Expose all headers that are set in response. exposed_headers = \ frozenset(response.headers.keys()) - _SIMPLE_RESPONSE_HEADERS response.headers[hdrs.ACCESS_CONTROL_EXPOSE_HEADERS] = \ ",".join(exposed_headers) elif options.expose_headers: # Expose predefined list of headers. response.headers[hdrs.ACCESS_CONTROL_EXPOSE_HEADERS] = \ ",".join(options.expose_headers) # Process according to CORS 6.1.3. # Set allowed origin. response.headers[hdrs.ACCESS_CONTROL_ALLOW_ORIGIN] = origin if options.allow_credentials: # Set allowed credentials. response.headers[hdrs.ACCESS_CONTROL_ALLOW_CREDENTIALS] = _TRUE @staticmethod def _parse_request_method(request: web.Request): """Parse Access-Control-Request-Method header of the preflight request """ method = request.headers.get(hdrs.ACCESS_CONTROL_REQUEST_METHOD) if method is None: raise web.HTTPForbidden( text="CORS preflight request failed: " "'Access-Control-Request-Method' header is not specified") # FIXME: validate method string (ABNF: method = token), if parsing # fails, raise HTTPForbidden. return method @staticmethod def _parse_request_headers(request: web.Request): """Parse Access-Control-Request-Headers header or the preflight request Returns set of headers in upper case. """ headers = request.headers.get(hdrs.ACCESS_CONTROL_REQUEST_HEADERS) if headers is None: return frozenset() # FIXME: validate each header string, if parsing fails, raise # HTTPForbidden. # FIXME: check, that headers split and stripped correctly (according # to ABNF). headers = (h.strip(" \t").upper() for h in headers.split(",")) # pylint: disable=bad-builtin return frozenset(filter(None, headers)) @asyncio.coroutine def _preflight_handler(self, request: web.Request): """CORS preflight request handler""" # Single path can be handled by multiple routes with different # methods, e.g.: # GET /user/1 # POST /user/1 # DELETE /user/1 # In this case several OPTIONS CORS handlers are configured for the # path, but aiohttp resolves all OPTIONS query to the first handler. # Gather all routes that corresponds to the current request path. # TODO: Test difference between request.raw_path and request.path. path = request.raw_path preflight_routes = [ route for route in self._preflight_route_settings.keys() if route.match(path) is not None] method_to_config = {} for route in preflight_routes: config, methods = self._preflight_route_settings[route] for method in methods: if method in method_to_config: if config != method_to_config[method]: # TODO: Catch logged errors in tests. _logger.error( "Path '{path}' matches several CORS handlers with " "different configuration. Using first matched " "configuration.".format( path=path)) method_to_config[method] = config # Handle according to part 6.2 of the CORS specification. origin = request.headers.get(hdrs.ORIGIN) if origin is None: # Terminate CORS according to CORS 6.2.1. raise web.HTTPForbidden( text="CORS preflight request failed: " "origin header is not specified in the request") # CORS 6.2.3. Doing it out of order is not an error. request_method = self._parse_request_method(request) # CORS 6.2.5. Doing it out of order is not an error. if request_method not in method_to_config: raise web.HTTPForbidden( text="CORS preflight request failed: " "request method '{}' is not allowed".format( request_method)) config = method_to_config[request_method] if not config: # No allowed origins for the route. # Terminate CORS according to CORS 6.2.1. raise web.HTTPForbidden( text="CORS preflight request failed: " "no origins are allowed") options = config.get(origin, config.get("*")) if options is None: # No configuration for the origin - deny. # Terminate CORS according to CORS 6.2.2. raise web.HTTPForbidden( text="CORS preflight request failed: " "origin '{}' is not allowed".format(origin)) # CORS 6.2.4 request_headers = self._parse_request_headers(request) # CORS 6.2.6 if options.allow_headers == "*": pass else: disallowed_headers = request_headers - options.allow_headers if disallowed_headers: raise web.HTTPForbidden( text="CORS preflight request failed: " "headers are not allowed: {}".format( ", ".join(disallowed_headers))) # Ok, CORS actual request with specified in the preflight request # parameters is allowed. # Set appropriate headers and return 200 response. response = web.Response() # CORS 6.2.7 response.headers[hdrs.ACCESS_CONTROL_ALLOW_ORIGIN] = origin if options.allow_credentials: # Set allowed credentials. response.headers[hdrs.ACCESS_CONTROL_ALLOW_CREDENTIALS] = _TRUE # CORS 6.2.8 if options.max_age is not None: response.headers[hdrs.ACCESS_CONTROL_MAX_AGE] = \ str(options.max_age) # CORS 6.2.9 # TODO: more optimal for client preflight request cache would be to # respond with ALL allowed methods. response.headers[hdrs.ACCESS_CONTROL_ALLOW_METHODS] = request_method # CORS 6.2.10 if request_headers: # Note: case of the headers in the request is changed, but this # shouldn't be a problem, since the headers should be compared in # the case-insensitive way. response.headers[hdrs.ACCESS_CONTROL_ALLOW_HEADERS] = \ ",".join(request_headers) return response PKNG$$aiohttp_cors/__init__.py# Copyright 2015 Vladimir Rutsky # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """CORS support for aiohttp. """ from typing import Mapping, Union, Any from aiohttp import web from .__about__ import ( __title__, __version__, __author__, __email__, __summary__, __uri__, __license__, __copyright__, ) from .resource_options import ResourceOptions from .cors_config import CorsConfig __all__ = ( "__title__", "__version__", "__author__", "__email__", "__summary__", "__uri__", "__license__", "__copyright__", "setup", "CorsConfig", "ResourceOptions", ) APP_CONFIG_KEY = "aiohttp_cors" def setup(app: web.Application, *, defaults: Mapping[str, Union[ResourceOptions, Mapping[str, Any]]]=None) -> CorsConfig: """Setup CORS processing for the application. To enable CORS for a resource you need to explicitly add route for that resource using `CorsConfig.add()` method:: app = aiohttp.web.Application( cors = aiohttp_cors.setup(app) cors.add( app.router.add_route("GET", "/resource", handler), { "*": aiohttp_cors.ResourceOptions( allow_credentials=True, expose_headers="*", allow_headers="*"), }) :param app: The application for which CORS will be configured. :param defaults: Default settings for origins. ) """ cors = CorsConfig(app, defaults=defaults) app[APP_CONFIG_KEY] = cors return cors PKgGaiohttp_cors/abc.py# Copyright 2015 Vladimir Rutsky # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Abstract base classes. """ from abc import ABCMeta, abstractmethod __all__ = ("AbstractRouterAdapter",) class AbstractRouterAdapter(metaclass=ABCMeta): """Router adapter is a minimal interface to router implementation that is required to implement CORS handling. """ @abstractmethod def route_methods(self, route): """Returns list of HTTP methods that route handles""" @abstractmethod def add_options_method_handler(self, route, handler): """Add OPTIONS method request handler that will be issued at the same paths as provided `route`. :return: Newly added route. """ PKeG;%Vaiohttp_cors/_log.py# Copyright 2015 Vladimir Rutsky # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """aiohttp_cors logger""" import logging __all__ = ("logger",) # pylint: disable=invalid-name logger = logging.getLogger("aiohttp_cors") PKgG ujj aiohttp_cors/resource_options.py# Copyright 2015 Vladimir Rutsky # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Resource CORS options class definition. """ import numbers import collections import collections.abc __all__ = ("ResourceOptions",) class ResourceOptions(collections.namedtuple( "Base", ("allow_credentials", "expose_headers", "allow_headers", "max_age"))): """Resource CORS options.""" __slots__ = () def __init__(self, *, allow_credentials=False, expose_headers=(), allow_headers=(), max_age=None): """Construct resource CORS options. Options will be normalized. :param allow_credentials: Is passing client credentials to the resource from other origin is allowed. See for the definition. :type allow_credentials: bool Is passing client credentials to the resource from other origin is allowed. :param expose_headers: Server headers that are allowed to be exposed to the client. Simple response headers are excluded from this set, see . :type expose_headers: sequence of strings or ``*`` string. :param allow_headers: Client headers that are allowed to be passed to the resource. See . :type allow_headers: sequence of strings or ``*`` string. :param max_age: How long the results of a preflight request can be cached in a preflight result cache (in seconds). See . """ super().__init__() def __new__(cls, *, allow_credentials=False, expose_headers=(), allow_headers=(), max_age=None): """Normalize source parameters and store them in namedtuple.""" if not isinstance(allow_credentials, bool): raise ValueError( "'allow_credentials' must be boolean, " "got '{}'".format(allow_credentials)) _allow_credentials = allow_credentials # `expose_headers` is either "*", or string with comma separated # headers. if expose_headers == "*": _expose_headers = expose_headers elif (not isinstance(expose_headers, collections.abc.Sequence) or isinstance(expose_headers, str)): raise ValueError( "'expose_headers' must be either '*', or sequence of strings, " "got '{}'".format(expose_headers)) elif expose_headers: # "Access-Control-Expose-Headers" ":" #field-name # TODO: Check that headers are valid. # TODO: Remove headers that in the _SIMPLE_RESPONSE_HEADERS set # according to # . _expose_headers = frozenset(expose_headers) else: _expose_headers = frozenset() # `allow_headers` is either "*", or set of headers in upper case. if allow_headers == "*": _allow_headers = allow_headers elif (not isinstance(allow_headers, collections.abc.Sequence) or isinstance(allow_headers, str)): raise ValueError( "'allow_headers' must be either '*', or sequence of strings, " "got '{}'".format(allow_headers)) else: # TODO: Check that headers are valid. _allow_headers = frozenset(h.upper() for h in allow_headers) if max_age is None: _max_age = None else: if not isinstance(max_age, numbers.Integral) or max_age < 0: raise ValueError( "'max_age' must be non-negative integer, " "got '{}'".format(max_age)) _max_age = max_age return super().__new__( cls, allow_credentials=_allow_credentials, expose_headers=_expose_headers, allow_headers=_allow_headers, max_age=_max_age) PK˲EH8D,aiohttp_cors/urldispatcher_router_adapter.py# Copyright 2015 Vladimir Rutsky # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """AbstractRouterAdapter for aiohttp.web.UrlDispatcher. """ import re from aiohttp import web from .abc import AbstractRouterAdapter __all__ = ("UrlDispatcherRouterAdapter",) class UrlDispatcherRouterAdapter(AbstractRouterAdapter): """Router adapter for aiohttp.web.UrlDispatcher""" def __init__(self, router: web.UrlDispatcher): self._router = router def route_methods(self, route: web.Route): """Returns list of HTTP methods that route handles""" return [route.method] def add_options_method_handler(self, route: web.Route, handler): method = "OPTIONS" if isinstance(route, web.PlainRoute): new_route = self._router.add_route(method, route._path, handler) elif isinstance(route, web.DynamicRoute): new_route = web.DynamicRoute( method, handler, None, route._pattern, route._formatter) self._router.register_route(new_route) elif isinstance(route, web.StaticRoute): # TODO: Use custom matches that uses `str.startswith()` if # regexp performance is not enough. pattern = re.compile("^" + re.escape(route._prefix)) new_route = web.DynamicRoute( method, handler, None, pattern, "") self._router.register_route(new_route) else: raise RuntimeError("Unhandled route type", route) return new_route PK;EHu,maiohttp_cors/__about__.py# Copyright 2015 Vladimir Rutsky # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Library meta information. This module must be stand-alone executable. """ __title__ = "aiohttp_cors" __version__ = "0.3.0" __author__ = "Vladimir Rutsky" __email__ = "vladimir@rutsky.org" __summary__ = "CORS support for aiohttp" __uri__ = "https://github.com/aio-libs/aiohttp_cors" __license__ = "Apache License, Version 2.0" __copyright__ = "2015 {}".format(__author__) PK`EHo66,aiohttp_cors-0.3.0.dist-info/DESCRIPTION.rstCORS support for aiohttp ======================== ``aiohttp_cors`` library implements `Cross Origin Resource Sharing (CORS) `__ support for `aiohttp `__ asyncio-powered asynchronous HTTP server. Jump directly to `Usage`_ part to see how to use ``aiohttp_cors``. Same-origin policy ================== Web security model is tightly connected to `Same-origin policy (SOP) `__. In short: web pages cannot *Read* resources which origin doesn't match origin of requested page, but can *Embed* (or *Execute*) resources and have limited ability to *Write* resources. Origin of a page is defined in the `Standard `__ as tuple ``(schema, host, port)`` (there is a notable exception with Internet Explorer: it doesn't use port to define origin, but uses it's own `Security Zones `__). Can *Embed* means that resource from other origin can be embedded into the page, e.g. by using ``