diff --git a/etc/heat/api-paste.ini b/etc/heat/api-paste.ini index 3e1094dc6..873c219ed 100644 --- a/etc/heat/api-paste.ini +++ b/etc/heat/api-paste.ini @@ -1,7 +1,7 @@ # heat-api pipeline [pipeline:heat-api] -pipeline = faultwrap versionnegotiation authtoken context apiv1app +pipeline = faultwrap ssl versionnegotiation authtoken context apiv1app # heat-api pipeline for standalone heat # ie. uses alternative auth backend that authenticates users against keystone @@ -12,7 +12,7 @@ pipeline = faultwrap versionnegotiation authtoken context apiv1app # flavor = standalone # [pipeline:heat-api-standalone] -pipeline = faultwrap versionnegotiation authpassword context apiv1app +pipeline = faultwrap ssl versionnegotiation authpassword context apiv1app # heat-api pipeline for custom cloud backends # i.e. in heat.conf: @@ -74,6 +74,10 @@ paste.filter_factory = heat.common.context:ContextMiddleware_filter_factory [filter:ec2authtoken] paste.filter_factory = heat.api.aws.ec2token:EC2Token_filter_factory +[filter:ssl] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = heat.api.openstack:sslmiddleware_filter + # Auth middleware that validates token against keystone [filter:authtoken] paste.filter_factory = heat.common.auth_token:filter_factory diff --git a/etc/heat/heat.conf.sample b/etc/heat/heat.conf.sample index 0f750d963..fd387385c 100644 --- a/etc/heat/heat.conf.sample +++ b/etc/heat/heat.conf.sample @@ -1,5 +1,15 @@ [DEFAULT] +# +# Options defined in heat.api.middleware.ssl +# + +# The HTTP Header that will be used to determine which the +# original request protocol scheme was, even if it was removed +# by an SSL terminator proxy. (string value) +#secure_proxy_ssl_header=X-Forwarded-Proto + + # # Options defined in heat.common.config # diff --git a/heat/api/middleware/ssl.py b/heat/api/middleware/ssl.py new file mode 100644 index 000000000..0a40e6c96 --- /dev/null +++ b/heat/api/middleware/ssl.py @@ -0,0 +1,41 @@ +# -*- encoding: utf-8 -*- +# +# 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. + +from oslo.config import cfg +from heat.common import wsgi + +ssl_middleware_opts = [ + cfg.StrOpt('secure_proxy_ssl_header', + default='X-Forwarded-Proto', + help="The HTTP Header that will be used to determine which " + "the original request protocol scheme was, even if it was " + "removed by an SSL terminator proxy.") +] +cfg.CONF.register_opts(ssl_middleware_opts) + + +class SSLMiddleware(wsgi.Middleware): + """A middleware that replaces the request wsgi.url_scheme environment + variable with the value of HTTP header configured in + secure_proxy_ssl_header if exists in the incoming request. + This is useful if the server is behind a SSL termination proxy. + """ + def __init__(self, application): + super(SSLMiddleware, self).__init__(application) + self.secure_proxy_ssl_header = 'HTTP_{0}'.format( + cfg.CONF.secure_proxy_ssl_header.upper().replace('-', '_')) + + def process_request(self, req): + req.environ['wsgi.url_scheme'] = req.environ.get( + self.secure_proxy_ssl_header, req.environ['wsgi.url_scheme']) diff --git a/heat/api/openstack/__init__.py b/heat/api/openstack/__init__.py index dfb897f34..5d3de3040 100644 --- a/heat/api/openstack/__init__.py +++ b/heat/api/openstack/__init__.py @@ -15,6 +15,7 @@ from heat.api.middleware.version_negotiation import VersionNegotiationFilter from heat.api.middleware.fault import FaultWrapper +from heat.api.middleware.ssl import SSLMiddleware from heat.api.openstack import versions @@ -25,3 +26,7 @@ def version_negotiation_filter(app, conf, **local_conf): def faultwrap_filter(app, conf, **local_conf): return FaultWrapper(app) + + +def sslmiddleware_filter(app, conf, **local_conf): + return SSLMiddleware(app) diff --git a/heat/tests/test_ssl_middleware.py b/heat/tests/test_ssl_middleware.py new file mode 100644 index 000000000..8f1e31bfc --- /dev/null +++ b/heat/tests/test_ssl_middleware.py @@ -0,0 +1,46 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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. + +import webob + +from heat.api.middleware import ssl +from oslo.config import cfg + +from heat.tests.common import HeatTestCase + + +class SSLMiddlewareTest(HeatTestCase): + scenarios = [('with_forwarded_proto_default_header', + dict(forwarded_protocol='https', + secure_proxy_ssl_header=None, + headers={'X-Forwarded-Proto': 'https'})), + ('with_forwarded_proto_non_default_header', + dict(forwarded_protocol='http', + secure_proxy_ssl_header='X-My-Forwarded-Proto', + headers={})), + ('without_forwarded_proto', + dict(forwarded_protocol='http', + secure_proxy_ssl_header=None, + headers={}))] + + def test_ssl_middleware(self): + if self.secure_proxy_ssl_header: + cfg.CONF.set_override('secure_proxy_ssl_header', + self.secure_proxy_ssl_header) + + middleware = ssl.SSLMiddleware(None) + request = webob.Request.blank('/stacks', headers=self.headers) + self.assertIsNone(middleware.process_request(request)) + self.assertEqual(self.forwarded_protocol, + request.environ['wsgi.url_scheme'])