diff --git a/etc/storyboard.conf.sample b/etc/storyboard.conf.sample index 36d99354..c09af9c3 100644 --- a/etc/storyboard.conf.sample +++ b/etc/storyboard.conf.sample @@ -50,6 +50,15 @@ lock_path = $state_path/lock # and subscriptions. # enable_notifications = True +[cors] +# W3C CORS configuration. For more information, see http://www.w3.org/TR/cors/ + +# List of permitted CORS domains. +# allowed_origins = https://storyboard.openstack.org, http://localhost:9000 + +# CORS browser options cache max age (in seconds) +# max_age=3600 + [notifications] # Host of the rabbitmq server. diff --git a/storyboard/api/app.py b/storyboard/api/app.py index 213f157c..a11fe43a 100644 --- a/storyboard/api/app.py +++ b/storyboard/api/app.py @@ -22,6 +22,7 @@ from wsgiref import simple_server from storyboard.api.auth.token_storage import impls as storage_impls from storyboard.api.auth.token_storage import storage from storyboard.api import config as api_config +from storyboard.api.middleware.cors_middleware import CORSMiddleware from storyboard.api.middleware import token_middleware from storyboard.api.middleware import user_id_hook from storyboard.api.v1.search import impls as search_engine_impls @@ -45,7 +46,16 @@ API_OPTS = [ default=False, help='Enable Notifications') ] +CORS_OPTS = [ + cfg.ListOpt('allowed_origins', + default=None, + help='List of permitted CORS origins.'), + cfg.IntOpt('max_age', + default=3600, + help='Maximum cache age of CORS preflight requests.') +] CONF.register_opts(API_OPTS) +CONF.register_opts(CORS_OPTS, 'cors') def get_pecan_config(): @@ -95,6 +105,17 @@ def setup_app(pecan_config=None): app = token_middleware.AuthTokenMiddleware(app) + # Setup CORS + if CONF.cors.allowed_origins: + app = CORSMiddleware(app, + allowed_origins=CONF.cors.allowed_origins, + allowed_methods=['GET', 'POST', 'PUT', 'DELETE', + 'OPTIONS'], + allowed_headers=['origin', 'authorization', + 'accept', 'x-total', 'x-limit', + 'x-marker', 'x-client'], + max_age=CONF.cors.max_age) + return app diff --git a/storyboard/api/middleware/cors_middleware.py b/storyboard/api/middleware/cors_middleware.py new file mode 100644 index 00000000..dae26393 --- /dev/null +++ b/storyboard/api/middleware/cors_middleware.py @@ -0,0 +1,113 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# 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. + +# Default allowed headers +ALLOWED_HEADERS = [ + 'origin', + 'authorization', + 'accept' +] +# Default allowed methods +ALLOWED_METHODS = [ + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'OPTIONS' +] + + +class CORSMiddleware(object): + """CORS Middleware. + + By providing a list of allowed origins, methods, headers, and a max-age, + this middleware will detect and apply the appropriate CORS headers so + that your web application may elegantly overcome the browser's + same-origin sandbox. + + For more information, see http://www.w3.org/TR/cors/ + """ + + def __init__(self, app, allowed_origins=None, allowed_methods=None, + allowed_headers=None, max_age=3600): + """Create a new instance of the CORS middleware. + + :param app: The application that is being wrapped. + :param allowed_origins: A list of allowed origins, as provided by the + 'Origin:' Http header. Must include protocol, host, and port. + :param allowed_methods: A list of allowed HTTP methods. + :param allowed_headers: A list of allowed HTTP headers. + :param max_age: A maximum CORS cache age in seconds. + :return: A new middleware instance. + """ + + # Wrapped app (or other middleware) + self.app = app + + # Allowed origins + self.allowed_origins = allowed_origins or [] + + # List of allowed headers. + self.allowed_headers = ','.join(allowed_headers or ALLOWED_HEADERS) + + # List of allowed methods. + self.allowed_methods = ','.join(allowed_methods or ALLOWED_METHODS) + + # Cache age. + self.max_age = str(max_age) + + def __call__(self, env, start_response): + """Serve an application request. + + :param env: Application environment parameters. + :param start_response: Wrapper method that starts the response. + :return: + """ + origin = env['HTTP_ORIGIN'] if 'HTTP_ORIGIN' in env else '' + method = env['REQUEST_METHOD'] if 'REQUEST_METHOD' in env else '' + + def replacement_start_response(status, headers, exc_info=None): + """Overrides the default response to attach CORS headers. + """ + + # Decorate the headers + headers.append(('Access-Control-Allow-Origin', + origin)) + headers.append(('Access-Control-Allow-Methods', + self.allowed_methods)) + headers.append(('Access-Control-Expose-Headers', + self.allowed_headers)) + headers.append(('Access-Control-Allow-Headers', + self.allowed_headers)) + headers.append(('Access-Control-Max-Age', + self.max_age)) + + return start_response(status, headers, exc_info) + + # Does this request match one of our origin domains? + if origin in self.allowed_origins: + + # Is this an OPTIONS request? + if method == 'OPTIONS': + options_headers = [('Content-Length', '0')] + replacement_start_response('204 No Content', options_headers) + return '' + else: + # Handle the request. + return self.app(env, replacement_start_response) + else: + # This is not a request for a permitted CORS domain. Return + # the response without the appropriate headers and let the browser + # figure out the details. + return self.app(env, start_response) diff --git a/storyboard/notifications/notification_hook.py b/storyboard/notifications/notification_hook.py index f972a28f..9ec486f8 100644 --- a/storyboard/notifications/notification_hook.py +++ b/storyboard/notifications/notification_hook.py @@ -27,7 +27,7 @@ class NotificationHook(hooks.PecanHook): def after(self, state): # Ignore get methods, we only care about changes. - if state.request.method == 'GET': + if state.request.method not in ['POST', 'PUT', 'DELETE']: return request = state.request