diff --git a/etc/heat/api-paste.ini b/etc/heat/api-paste.ini index bd5af18a49..13af43cfca 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 ssl versionnegotiation authurl authtoken context apiv1app +pipeline = faultwrap ssl xforwardedfor versionnegotiation authurl 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 ssl versionnegotiation authurl authtoken context apiv1app # flavor = standalone # [pipeline:heat-api-standalone] -pipeline = faultwrap ssl versionnegotiation authurl authpassword context apiv1app +pipeline = faultwrap ssl xforwardedfor versionnegotiation authurl authpassword context apiv1app # heat-api pipeline for custom cloud backends # i.e. in heat.conf: @@ -20,7 +20,7 @@ pipeline = faultwrap ssl versionnegotiation authurl authpassword context apiv1ap # flavor = custombackend # [pipeline:heat-api-custombackend] -pipeline = faultwrap versionnegotiation context custombackendauth apiv1app +pipeline = faultwrap xforwardedfor versionnegotiation context custombackendauth apiv1app # heat-api-cfn pipeline [pipeline:heat-api-cfn] @@ -33,12 +33,12 @@ pipeline = cfnversionnegotiation ec2authtoken context apicfnv1app # heat-api-cloudwatch pipeline [pipeline:heat-api-cloudwatch] -pipeline = versionnegotiation ec2authtoken authtoken context apicwapp +pipeline = xforwardedfor versionnegotiation ec2authtoken authtoken context apicwapp # heat-api-cloudwatch pipeline for standalone heat # relies exclusively on authenticating with ec2 signed requests [pipeline:heat-api-cloudwatch-standalone] -pipeline = versionnegotiation ec2authtoken context apicwapp +pipeline = xforwardedfor versionnegotiation ec2authtoken context apicwapp [app:apiv1app] paste.app_factory = heat.common.wsgi:app_factory @@ -52,6 +52,10 @@ heat.app_factory = heat.api.cfn.v1:API paste.app_factory = heat.common.wsgi:app_factory heat.app_factory = heat.api.cloudwatch:API +[filter:xforwardedfor] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = heat.api.openstack:x_forwarded_for_middleware_filter + [filter:versionnegotiation] paste.filter_factory = heat.common.wsgi:filter_factory heat.filter_factory = heat.api.openstack:version_negotiation_filter diff --git a/etc/heat/heat.conf.sample b/etc/heat/heat.conf.sample index d1d3973289..d2ca0585d4 100644 --- a/etc/heat/heat.conf.sample +++ b/etc/heat/heat.conf.sample @@ -10,6 +10,15 @@ #secure_proxy_ssl_header=X-Forwarded-Proto +# +# Options defined in heat.api.middleware.x_forwarded_for +# + +# The HTTP header that will be used as remote address. (string +# value) +#forward_header_name=X-Forwarded-For + + # # Options defined in heat.common.config # diff --git a/heat/api/middleware/x_forwarded_for.py b/heat/api/middleware/x_forwarded_for.py new file mode 100644 index 0000000000..c6aedd2702 --- /dev/null +++ b/heat/api/middleware/x_forwarded_for.py @@ -0,0 +1,35 @@ +# -*- 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 + +x_forwarded_middleware_opts = [ + cfg.StrOpt('forward_header_name', + default='X-Forwarded-For', + help="The HTTP header that will be used as remote address.") +] +cfg.CONF.register_opts(x_forwarded_middleware_opts) + + +class XForwardedForMiddleware(wsgi.Middleware): + """A middleware that replaces the request hostname with proxy hostname. + """ + def __init__(self, application): + super(XForwardedForMiddleware, self).__init__(application) + + def process_request(self, req): + # If 'forward_header_name' header was not found, then do not + # change the host name + req.host = req.headers.get(cfg.CONF.forward_header_name, req.host) diff --git a/heat/api/openstack/__init__.py b/heat/api/openstack/__init__.py index e90b07df2f..5613202a3d 100644 --- a/heat/api/openstack/__init__.py +++ b/heat/api/openstack/__init__.py @@ -14,6 +14,7 @@ from heat.api.middleware.fault import FaultWrapper from heat.api.middleware.ssl import SSLMiddleware from heat.api.middleware.version_negotiation import VersionNegotiationFilter +from heat.api.middleware.x_forwarded_for import XForwardedForMiddleware from heat.api.openstack import versions @@ -28,3 +29,7 @@ def faultwrap_filter(app, conf, **local_conf): def sslmiddleware_filter(app, conf, **local_conf): return SSLMiddleware(app) + + +def x_forwarded_for_middleware_filter(app, conf, **local_conf): + return XForwardedForMiddleware(app) diff --git a/heat/tests/test_x_forwarded_for_middleware.py b/heat/tests/test_x_forwarded_for_middleware.py new file mode 100644 index 0000000000..3badaeb564 --- /dev/null +++ b/heat/tests/test_x_forwarded_for_middleware.py @@ -0,0 +1,53 @@ +# -*- 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. + +import webob + +from heat.api.middleware import x_forwarded_for +from oslo.config import cfg + +from heat.tests.common import HeatTestCase + + +class XForwardedForMiddlewareTest(HeatTestCase): + + def test_default_header_found(self): + headers = {'X-Forwarded-For': 'www.test.com'} + middleware = x_forwarded_for.XForwardedForMiddleware(None) + request = webob.Request.blank('/stacks', headers=headers) + self.assertIsNone(middleware.process_request(request)) + self.assertEqual('www.test.com', request.host) + + def test_custom_header_found(self): + cfg.CONF.set_override('forward_header_name', 'X-Fwd-Custom') + headers = {'X-Fwd-Custom': 'www.test.com'} + middleware = x_forwarded_for.XForwardedForMiddleware(None) + request = webob.Request.blank('/stacks', headers=headers) + self.assertIsNone(middleware.process_request(request)) + self.assertEqual('www.test.com', request.host) + + def test_default_header_not_found(self): + headers = {} + middleware = x_forwarded_for.XForwardedForMiddleware(None) + request = webob.Request.blank('/stacks', headers=headers) + self.assertIsNone(middleware.process_request(request)) + self.assertEqual('localhost:80', request.host) + + def test_custom_header_not_found(self): + cfg.CONF.set_override('forward_header_name', 'X-Fwd-Custom') + headers = {} + middleware = x_forwarded_for.XForwardedForMiddleware(None) + request = webob.Request.blank('/stacks', headers=headers) + self.assertIsNone(middleware.process_request(request)) + self.assertEqual('localhost:80', request.host)