diff --git a/.travis.yml b/.travis.yml index 2e6cb43..5df10d4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,9 @@ env: install: - pip install -q $DJANGO coveralls script: -- coverage run --source=axes runtests.py +- coverage run -a --source=axes runtests.py +- coverage run -a --source=axes runtests_proxy.py +- coverage run -a --source=axes runtests_proxy_custom_header.py - coverage report after_success: - coveralls diff --git a/axes/decorators.py b/axes/decorators.py index 1b6005f..31e9904 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -37,15 +37,18 @@ def is_ipv6(ip): def get_ip(request): - ip = request.META.get('REMOTE_ADDR', '') + """Parse IP address from REMOTE_ADDR or + AXES_REVERSE_PROXY_HEADER if AXES_BEHIND_REVERSE_PROXY is set.""" if BEHIND_REVERSE_PROXY: - ip = request.META.get(REVERSE_PROXY_HEADER, '').split(',', 1)[0] - ip = ip.strip() + # For requests originating from behind a reverse proxy, + # resolve the IP address from the given AXES_REVERSE_PROXY_HEADER. + # AXES_REVERSE_PROXY_HEADER defaults to HTTP_X_FORWARDED_FOR if not given, + # which is the Django calling format for the HTTP X-Forwarder-For header. + # Please see RFC7239 for additional information: + # https://tools.ietf.org/html/rfc7239#section-5 - # IIS seems to add the port number to HTTP_X_FORWARDED_FOR - if ':' in ip: - ip = ip.split(':')[0] + ip = request.META.get(REVERSE_PROXY_HEADER, '') if not ip: raise Warning( @@ -54,11 +57,21 @@ def get_ip(request): 'server settings to make sure this header value is being ' 'passed. Header value {0}'.format(REVERSE_PROXY_HEADER) ) - if not is_ipv6(ip): - # Fix for IIS adding client port number to 'HTTP_X_FORWARDED_FOR' header (removes port number). - ip = ''.join(ip.split(':')[:-1]) - return ip + # X-Forwarded-For IPs can have multiple IPs of which the first one is the + # originating reverse and the rest are proxies that are between the client + ip = ip.split(',', 1)[0] + + # As spaces are permitted between given X-Forwarded-For IP addresses, strip them as well + ip = ip.strip() + + # Fix IIS adding client port number to 'X-Forwarded-For' header (strip port) + if not is_ipv6(ip): + ip = ip.split(':', 1)[0] + + return ip + + return request.META.get('REMOTE_ADDR', '') def query2str(items, max_length=1024): diff --git a/axes/test_settings_proxy.py b/axes/test_settings_proxy.py new file mode 100644 index 0000000..2adf793 --- /dev/null +++ b/axes/test_settings_proxy.py @@ -0,0 +1,3 @@ +from .test_settings import * + +AXES_BEHIND_REVERSE_PROXY = True diff --git a/axes/test_settings_proxy_custom_header.py b/axes/test_settings_proxy_custom_header.py new file mode 100644 index 0000000..8e092ae --- /dev/null +++ b/axes/test_settings_proxy_custom_header.py @@ -0,0 +1,3 @@ +from .test_settings_proxy import * + +AXES_REVERSE_PROXY_HEADER = 'HTTP_X_AXES_CUSTOM_HEADER' diff --git a/axes/tests.py b/axes/tests.py index eb894e3..87795ca 100644 --- a/axes/tests.py +++ b/axes/tests.py @@ -4,14 +4,17 @@ import time import json import datetime +from mock import patch + +from django.conf import settings from django.test import TestCase from django.test.utils import override_settings from django.contrib.auth.models import User from django.core.urlresolvers import NoReverseMatch from django.core.urlresolvers import reverse from django.utils import six -from mock import patch +from axes.decorators import get_ip from axes.settings import COOLOFF_TIME from axes.settings import FAILURE_LIMIT from axes.models import AccessAttempt, AccessLog @@ -19,6 +22,11 @@ from axes.signals import user_locked_out from axes.utils import reset, iso8601 +class MockRequest: + def __init__(self): + self.META = dict() + + class AccessAttemptTest(TestCase): """Test case using custom settings for testing """ @@ -392,3 +400,67 @@ class UtilsTest(TestCase): self.assertTrue(is_ipv6('ff80::220:16ff:fec9:1')) self.assertFalse(is_ipv6('67.255.125.204')) self.assertFalse(is_ipv6('foo')) + + +class GetIPProxyTest(TestCase): + """Test get_ip returns correct addresses with proxy + """ + def setUp(self): + self.request = MockRequest() + + def test_iis_ipv4_port_stripping(self): + self.ip = '192.168.1.1' + + valid_headers = [ + '192.168.1.1:6112', + '192.168.1.1:6033, 192.168.1.2:9001', + ] + + for header in valid_headers: + self.request.META['HTTP_X_FORWARDED_FOR'] = header + self.assertEqual(self.ip, get_ip(self.request)) + + def test_valid_ipv4_parsing(self): + self.ip = '192.168.1.1' + + valid_headers = [ + '192.168.1.1', + '192.168.1.1, 192.168.1.2', + ' 192.168.1.1 , 192.168.1.2 ', + ' 192.168.1.1 , 2001:db8:cafe::17 ', + ] + + for header in valid_headers: + self.request.META['HTTP_X_FORWARDED_FOR'] = header + self.assertEqual(self.ip, get_ip(self.request)) + + def test_valid_ipv6_parsing(self): + self.ip = '2001:db8:cafe::17' + + valid_headers = [ + '2001:db8:cafe::17', + '2001:db8:cafe::17 , 2001:db8:cafe::18', + '2001:db8:cafe::17, 2001:db8:cafe::18, 192.168.1.1', + ] + + for header in valid_headers: + self.request.META['HTTP_X_FORWARDED_FOR'] = header + self.assertEqual(self.ip, get_ip(self.request)) + + +class GetIPProxyCustomHeaderTest(TestCase): + """Test that get_ip returns correct addresses with a custom proxy header + """ + def setUp(self): + self.request = MockRequest() + + def test_custom_header_parsing(self): + self.ip = '2001:db8:cafe::17' + + valid_headers = [ + ' 2001:db8:cafe::17 , 2001:db8:cafe::18', + ] + + for header in valid_headers: + self.request.META[settings.AXES_REVERSE_PROXY_HEADER] = header + self.assertEqual(self.ip, get_ip(self.request)) diff --git a/runtests.py b/runtests.py index 3003d3b..f76e943 100755 --- a/runtests.py +++ b/runtests.py @@ -1,4 +1,5 @@ #!/usr/bin/env python + import os import sys @@ -6,10 +7,18 @@ import django from django.conf import settings from django.test.utils import get_runner -if __name__ == "__main__": - os.environ['DJANGO_SETTINGS_MODULE'] = 'axes.test_settings' + +def run_tests(settings_module, *modules): + os.environ['DJANGO_SETTINGS_MODULE'] = settings_module django.setup() TestRunner = get_runner(settings) test_runner = TestRunner() - failures = test_runner.run_tests(["axes"]) + failures = test_runner.run_tests(*modules) sys.exit(bool(failures)) + + +if __name__ == '__main__': + run_tests('axes.test_settings', [ + 'axes.tests.AccessAttemptTest', + 'axes.tests.UtilsTest', + ]) diff --git a/runtests_proxy.py b/runtests_proxy.py new file mode 100644 index 0000000..6ffa46d --- /dev/null +++ b/runtests_proxy.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python + +from runtests import run_tests + +if __name__ == '__main__': + run_tests('axes.test_settings_proxy', [ + 'axes.tests.GetIPProxyTest', + ]) diff --git a/runtests_proxy_custom_header.py b/runtests_proxy_custom_header.py new file mode 100644 index 0000000..19d5c50 --- /dev/null +++ b/runtests_proxy_custom_header.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python + +from runtests import run_tests + +if __name__ == '__main__': + run_tests('axes.test_settings_proxy_custom_header', [ + 'axes.tests.GetIPProxyCustomHeaderTest', + ])