diff --git a/awx/api/generics.py b/awx/api/generics.py index a6ec9b7939..e3f553805d 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -98,6 +98,18 @@ class APIView(views.APIView): self.time_started = time.time() if getattr(settings, 'SQL_DEBUG', False): self.queries_before = len(connection.queries) + + # If there are any custom headers in REMOTE_HOST_HEADERS, make sure + # they respect the proxy whitelist + if all([ + settings.PROXY_IP_WHITELIST, + request.environ.get('REMOTE_ADDR') not in settings.PROXY_IP_WHITELIST, + request.environ.get('REMOTE_HOST') not in settings.PROXY_IP_WHITELIST + ]): + for custom_header in settings.REMOTE_HOST_HEADERS: + if custom_header.startswith('HTTP_'): + request.environ.pop(custom_header, None) + drf_request = super(APIView, self).initialize_request(request, *args, **kwargs) request.drf_request = drf_request return drf_request diff --git a/awx/main/conf.py b/awx/main/conf.py index c62e9632cf..365336255c 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -82,6 +82,21 @@ register( category_slug='system', ) +register( + 'PROXY_IP_WHITELIST', + field_class=fields.StringListField, + label=_('Proxy IP Whitelist'), + help_text=_("If Tower is behind a reverse proxy/load balancer, use this setting " + "to whitelist the proxy IP addresses from which Tower should trust " + "custom REMOTE_HOST_HEADERS header values\n" + "REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST']\n" + "PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101']\n" + "If this setting is an empty list (the default), the headers specified by " + "REMOTE_HOST_HEADERS will be trusted unconditionally')"), + category=_('System'), + category_slug='system', +) + def _load_default_license_from_file(): try: diff --git a/awx/main/tests/functional/api/test_generic.py b/awx/main/tests/functional/api/test_generic.py new file mode 100644 index 0000000000..94011f0c21 --- /dev/null +++ b/awx/main/tests/functional/api/test_generic.py @@ -0,0 +1,62 @@ +import pytest + +from django.core.urlresolvers import reverse + + +@pytest.mark.django_db +def test_proxy_ip_whitelist(get, patch, admin): + url = reverse('api:setting_singleton_detail', args=('system',)) + patch(url, user=admin, data={ + 'REMOTE_HOST_HEADERS': [ + 'HTTP_X_FROM_THE_LOAD_BALANCER', + 'REMOTE_ADDR', + 'REMOTE_HOST' + ] + }) + + class HeaderTrackingMiddleware(object): + environ = {} + + def process_request(self, request): + pass + + def process_response(self, request, response): + self.environ = request.environ + + # By default, `PROXY_IP_WHITELIST` is disabled, so custom `REMOTE_HOST_HEADERS` + # should just pass through + middleware = HeaderTrackingMiddleware() + get(url, user=admin, middleware=middleware, + HTTP_X_FROM_THE_LOAD_BALANCER='some-actual-ip') + assert middleware.environ['HTTP_X_FROM_THE_LOAD_BALANCER'] == 'some-actual-ip' + + # If `PROXY_IP_WHITELIST` is restricted to 10.0.1.100 and we make a request + # from 8.9.10.11, the custom `HTTP_X_FROM_THE_LOAD_BALANCER` header should + # be stripped + patch(url, user=admin, data={ + 'PROXY_IP_WHITELIST': ['10.0.1.100'] + }) + middleware = HeaderTrackingMiddleware() + get(url, user=admin, middleware=middleware, REMOTE_ADDR='8.9.10.11', + HTTP_X_FROM_THE_LOAD_BALANCER='some-actual-ip') + assert 'HTTP_X_FROM_THE_LOAD_BALANCER' not in middleware.environ + + # If 8.9.10.11 is added to `PROXY_IP_WHITELIST` the + # `HTTP_X_FROM_THE_LOAD_BALANCER` header should be passed through again + patch(url, user=admin, data={ + 'PROXY_IP_WHITELIST': ['10.0.1.100', '8.9.10.11'] + }) + middleware = HeaderTrackingMiddleware() + get(url, user=admin, middleware=middleware, REMOTE_ADDR='8.9.10.11', + HTTP_X_FROM_THE_LOAD_BALANCER='some-actual-ip') + assert middleware.environ['HTTP_X_FROM_THE_LOAD_BALANCER'] == 'some-actual-ip' + + # Allow whitelisting of proxy hostnames in addition to IP addresses + patch(url, user=admin, data={ + 'PROXY_IP_WHITELIST': ['my.proxy.example.org'] + }) + middleware = HeaderTrackingMiddleware() + get(url, user=admin, middleware=middleware, REMOTE_ADDR='8.9.10.11', + REMOTE_HOST='my.proxy.example.org', + HTTP_X_FROM_THE_LOAD_BALANCER='some-actual-ip') + assert middleware.environ['HTTP_X_FROM_THE_LOAD_BALANCER'] == 'some-actual-ip' diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 8fe6baf4e6..d48b50eca2 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -152,6 +152,15 @@ ALLOWED_HOSTS = [] # reverse proxy. REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST'] +# If Tower is behind a reverse proxy/load balancer, use this setting to +# whitelist the proxy IP addresses from which Tower should trust custom +# REMOTE_HOST_HEADERS header values +# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST'] +# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101'] +# If this setting is an empty list (the default), the headers specified by +# REMOTE_HOST_HEADERS will be trusted unconditionally') +PROXY_IP_WHITELIST = [] + # Note: This setting may be overridden by database settings. STDOUT_MAX_BYTES_DISPLAY = 1048576 diff --git a/awx/settings/local_settings.py.docker_compose b/awx/settings/local_settings.py.docker_compose index 1202b1cbe1..024a57ea60 100644 --- a/awx/settings/local_settings.py.docker_compose +++ b/awx/settings/local_settings.py.docker_compose @@ -130,6 +130,15 @@ SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y' # reverse proxy. REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST'] +# If Tower is behind a reverse proxy/load balancer, use this setting to +# whitelist the proxy IP addresses from which Tower should trust custom +# REMOTE_HOST_HEADERS header values +# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST'] +# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101'] +# If this setting is an empty list (the default), the headers specified by +# REMOTE_HOST_HEADERS will be trusted unconditionally') +PROXY_IP_WHITELIST = [] + # Define additional environment variables to be passed to subprocess started by # the celery task. #AWX_TASK_ENV['FOO'] = 'BAR' diff --git a/awx/settings/local_settings.py.example b/awx/settings/local_settings.py.example index 2996a8a28e..ecf43f2ced 100644 --- a/awx/settings/local_settings.py.example +++ b/awx/settings/local_settings.py.example @@ -87,6 +87,15 @@ SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y' # reverse proxy. REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST'] +# If Tower is behind a reverse proxy/load balancer, use this setting to +# whitelist the proxy IP addresses from which Tower should trust custom +# REMOTE_HOST_HEADERS header values +# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST'] +# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101'] +# If this setting is an empty list (the default), the headers specified by +# REMOTE_HOST_HEADERS will be trusted unconditionally') +PROXY_IP_WHITELIST = [] + # Define additional environment variables to be passed to subprocess started by # the celery task. #AWX_TASK_ENV['FOO'] = 'BAR'