Repository: jazzband/django-axes

Issue:
Add option for configuring number of proxies for end user IP calculation
# HTTP header injection vulnerability in Axes

At the moment we implement a naive default of using the first IP address in `X-Forwarded-For` address list for calculating the end user IP address. In fact, this should depend on the number of proxies between the server and the client. For example, if we are running behind a nginx proxy or an [AWS Elastic Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html), the proxy *appends* the client address to the *end* of the list of given `X-Forwarded-For` headers. This leads to the current version of Axes having a [XFF spoofing vulnerability](https://www.whitehatsec.com/blog/top-3-proxy-issues-that-no-one-ever-told-you/).

### Vulnerability description

Clients can attempt a higher number of logins than they should by setting, for example, the X-Forwarded-For header to various different values (known as header injection or spoofing). Axes treats each different spoofed value as an unique client.

For example, let's say the proxy and client supply the following headers:

#### Case 1:  a trusted proxy appends client IP with no preset when we have one (1) known proxy in the chain

    X-Forwarded-For: client-ip-appended-by-trusted-proxy

#### Case 2: two (2) known proxies inserting first a proxy and then the client IP into the chain

    X-Forwarded-For: client-ip-appended-by-trusted-proxy, proxy-ip-appended-by-trusted-proxy

#### Case 3: malicious client injects (spoofs) a malicious X-Forwarded-For header into the request when we have one (1) trusted proxy, proxy adds actual client IP last

    X-Forwarded-For: 127.0.0.1, client-ip-appended-by-trusted-proxy

Axes treats the first IP, `127.0.0.1`, as an unique client; client can test multiple logins this way

#### Case 4: malicious client injects (spoofs) a malicious X-Forwarded-For header into the request when we have two (2) trusted proxies, proxies append client and proxy IPs last in the chain

    X-Forwarded-For: 127.0.0.1, client-ip-appended-by-trusted-proxy, proxy-ip-appended-by-trusted-proxy

Axes treats `127.0.0.1` as an unique client; client can, again, test multiple logins this way

### Current functionality

We have a bunch of different scenarios and we can have any other header configured as the client IP denominator, such as `X-Real-IP` by nginx of the default `X-Forwarded-For` which is often used with Apache, nginx, ELB, and the like.

We always just use the first IP in the defined header to calculate the client IP and disregard different configurations for number of proxies Axes can be deployed with.

### Fix

We should provide a configurable value for setting the number of proxies that would default to `0` proxies if not set; [this would be compatible with the current behaviour](https://github.com/jazzband/django-axes/blob/master/axes/decorators.py#L65).

If we could configure the IPs we could implement the following and count the IP addresses from the end of the list:

```python
from django.conf import settings
from django.core.exceptions import SuspiciousOperation

# Axes settings are configured, at the moment, with the following defaults:

BEHIND_REVERSE_PROXY = getattr(settings, 'AXES_BEHIND_REVERSE_PROXY', False)
REVERSE_PROXY_HEADER = getattr(settings, 'AXES_REVERSE_PROXY_HEADER', 'X-Forwarded-For')

# We could add the following default configuraton value:

NUM_PROXIES = getattr(settings, 'AXES_NUM_PROXIES', 0)

# IP resolution is corrected with a different IP fetching implementation

def get_ip(
        request, 
        num_proxies=NUM_PROXIES, 
        header_name=REVERSE_PROXY_HEADER,
        behind_reverse=BEHIND_REVERSE_PROXY
):
    """Calculate client IP dynamically from given request
    
    This function allows for custom header and number of proxies
    to be used when calculating end user IPs from HTTP headers.

    For example, take the following values:

        request.META['X-Forwarded-For'] = '127.0.0.1, 10.10.10.10'
        num_proxies = 1
        header_name = 'X-Forwarded-For'
        behind_reverse = True

    We can dependably calculate the end user IP from these:

        ip := '10.10.10.10'
            = ['127.0.0.1', '10.10.10.10'][-1]           
            = [addr.strip() for addr in '127.0.0.1, 10.10.10.10'].split(',')][-1]
            = [addr.strip() for addr in request.META['X-Forwarded-For'].split(',')][-1]
            = [addr.strip() for addr in request.META[header_name].split(',')][-num_proxies]
    """

    if behind_reverse:
        header_value = request.META[header_name]
        addresses = [addr.strip() for addr in header_value.split(',')]
        if addresses < num_proxies:
            raise SuspiciousOperation('Invalid request header {}: {}'.format(header_name, header_value)) 
        return addresses[-num_proxies]
    return request.META['REMOTE_ADDR']
```

### Effects for current users if a fix is upstreamed

Unaffected. 

- Fetching the `-0` IP from the IP address list is equivalent to fetching `0`, so all existing apps would be unaffected unless configured with new `AXES_NUM_PROXIES` settings. 
- This would still work with e.g. `X-Real-IP` header like `X-Real-IP: client-ip-by-nginx` and with settings set to something `AXES_NUM_PROXIES = 0` OR `AXES_NUM_PROXIES = 1` and `AXES_REVERSE_PROXY_HEADER = 'X-Real-IP'`

The functionality is implemented in configurable fashion in [Django REST Framework](http://www.django-rest-framework.org/api-guide/throttling/#how-clients-are-identified) and other software such as Werkzeug, and [Django middleware exists with XFF correction](https://github.com/codetry/xff).
Add option for configuring number of proxies for end user IP calculation
# HTTP header injection vulnerability in Axes

At the moment we implement a naive default of using the first IP address in `X-Forwarded-For` address list for calculating the end user IP address. In fact, this should depend on the number of proxies between the server and the client. For example, if we are running behind a nginx proxy or an [AWS Elastic Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html), the proxy *appends* the client address to the *end* of the list of given `X-Forwarded-For` headers. This leads to the current version of Axes having a [XFF spoofing vulnerability](https://www.whitehatsec.com/blog/top-3-proxy-issues-that-no-one-ever-told-you/).

### Vulnerability description

Clients can attempt a higher number of logins than they should by setting, for example, the X-Forwarded-For header to various different values (known as header injection or spoofing). Axes treats each different spoofed value as an unique client.

For example, let's say the proxy and client supply the following headers:

#### Case 1:  a trusted proxy appends client IP with no preset when we have one (1) known proxy in the chain

    X-Forwarded-For: client-ip-appended-by-trusted-proxy

#### Case 2: two (2) known proxies inserting first a proxy and then the client IP into the chain

    X-Forwarded-For: client-ip-appended-by-trusted-proxy, proxy-ip-appended-by-trusted-proxy

#### Case 3: malicious client injects (spoofs) a malicious X-Forwarded-For header into the request when we have one (1) trusted proxy, proxy adds actual client IP last

    X-Forwarded-For: 127.0.0.1, client-ip-appended-by-trusted-proxy

Axes treats the first IP, `127.0.0.1`, as an unique client; client can test multiple logins this way

#### Case 4: malicious client injects (spoofs) a malicious X-Forwarded-For header into the request when we have two (2) trusted proxies, proxies append client and proxy IPs last in the chain

    X-Forwarded-For: 127.0.0.1, client-ip-appended-by-trusted-proxy, proxy-ip-appended-by-trusted-proxy

Axes treats `127.0.0.1` as an unique client; client can, again, test multiple logins this way

### Current functionality

We have a bunch of different scenarios and we can have any other header configured as the client IP denominator, such as `X-Real-IP` by nginx of the default `X-Forwarded-For` which is often used with Apache, nginx, ELB, and the like.

We always just use the first IP in the defined header to calculate the client IP and disregard different configurations for number of proxies Axes can be deployed with.

### Fix

We should provide a configurable value for setting the number of proxies that would default to `0` proxies if not set; [this would be compatible with the current behaviour](https://github.com/jazzband/django-axes/blob/master/axes/decorators.py#L65).

If we could configure the IPs we could implement the following and count the IP addresses from the end of the list:

```python
from django.conf import settings
from django.core.exceptions import SuspiciousOperation

# Axes settings are configured, at the moment, with the following defaults:

BEHIND_REVERSE_PROXY = getattr(settings, 'AXES_BEHIND_REVERSE_PROXY', False)
REVERSE_PROXY_HEADER = getattr(settings, 'AXES_REVERSE_PROXY_HEADER', 'X-Forwarded-For')

# We could add the following default configuraton value:

NUM_PROXIES = getattr(settings, 'AXES_NUM_PROXIES', 0)

# IP resolution is corrected with a different IP fetching implementation

def get_ip(
        request, 
        num_proxies=NUM_PROXIES, 
        header_name=REVERSE_PROXY_HEADER,
        behind_reverse=BEHIND_REVERSE_PROXY
):
    """Calculate client IP dynamically from given request
    
    This function allows for custom header and number of proxies
    to be used when calculating end user IPs from HTTP headers.

    For example, take the following values:

        request.META['X-Forwarded-For'] = '127.0.0.1, 10.10.10.10'
        num_proxies = 1
        header_name = 'X-Forwarded-For'
        behind_reverse = True

    We can dependably calculate the end user IP from these:

        ip := '10.10.10.10'
            = ['127.0.0.1', '10.10.10.10'][-1]           
            = [addr.strip() for addr in '127.0.0.1, 10.10.10.10'].split(',')][-1]
            = [addr.strip() for addr in request.META['X-Forwarded-For'].split(',')][-1]
            = [addr.strip() for addr in request.META[header_name].split(',')][-num_proxies]
    """

    if behind_reverse:
        header_value = request.META[header_name]
        addresses = [addr.strip() for addr in header_value.split(',')]
        if addresses < num_proxies:
            raise SuspiciousOperation('Invalid request header {}: {}'.format(header_name, header_value)) 
        return addresses[-num_proxies]
    return request.META['REMOTE_ADDR']
```

### Effects for current users if a fix is upstreamed

Unaffected. 

- Fetching the `-0` IP from the IP address list is equivalent to fetching `0`, so all existing apps would be unaffected unless configured with new `AXES_NUM_PROXIES` settings. 
- This would still work with e.g. `X-Real-IP` header like `X-Real-IP: client-ip-by-nginx` and with settings set to something `AXES_NUM_PROXIES = 0` OR `AXES_NUM_PROXIES = 1` and `AXES_REVERSE_PROXY_HEADER = 'X-Real-IP'`

The functionality is implemented in configurable fashion in [Django REST Framework](http://www.django-rest-framework.org/api-guide/throttling/#how-clients-are-identified) and other software such as Werkzeug, and [Django middleware exists with XFF correction](https://github.com/codetry/xff).


Discussion:
Good to go, please work on a PR. Thanks.
Good to go, please work on a PR. Thanks.

Resolving patch:
diff --git a/axes/decorators.py b/axes/decorators.py
--- a/axes/decorators.py
+++ b/axes/decorators.py
@@ -51,15 +51,15 @@ def get_ip(request):
     if BEHIND_REVERSE_PROXY:
         # 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.
+        # AXES_REVERSE_PROXY_HEADER defaults to HTTP_X_FORWARDED_FOR,
+        # which is the Django name for the HTTP X-Forwarder-For header.
         # Please see RFC7239 for additional information:
         #   https://tools.ietf.org/html/rfc7239#section-5
 
         # The REVERSE_PROXY_HEADER HTTP header is a list
         # of potentionally unsecure IPs, for example:
         #   X-Forwarded-For: 1.1.1.1, 11.11.11.11:8080, 111.111.111.111
-        ip = request.META.get(REVERSE_PROXY_HEADER, '')
+        ip_str = request.META.get(REVERSE_PROXY_HEADER, '')
 
         # We need to know the number of proxies present in the request chain
         # in order to securely calculate the one IP that is the real client IP.
@@ -68,23 +68,45 @@ def get_ip(request):
         # configurations, with e.g. the X-Forwarded-For header containing
         # the originating client IP, proxies and possibly spoofed values.
         #
-        # If you are using a special header for client calculation such as
-        # the X-Real-IP or the like with nginx, please check this configuration.
+        # If you are using a special header for client calculation such as the
+        # X-Real-IP or the like with nginx, please check this configuration.
         #
         # Please see discussion for more information:
         #   https://github.com/jazzband/django-axes/issues/224
-        ip = [ip.strip() for ip in ip.split(',')][-NUM_PROXIES]
+        ip_list = [ip.strip() for ip in ip_str.split(',')]
 
-        # Fix IIS adding client port number to 'X-Forwarded-For' header (strip port)
-        if not is_ipv6(ip):
-            ip = ip.split(':', 1)[0]
+        # Pick the nth last IP in the given list of addresses after parsing
+        if len(ip_list) >= NUM_PROXIES:
+            ip = ip_list[-NUM_PROXIES]
+
+            # Fix IIS adding client port number to the
+            # 'X-Forwarded-For' header (strip port)
+            if not is_ipv6(ip):
+                ip = ip.split(':', 1)[0]
+
+        # If nth last is not found, default to no IP and raise a warning
+        else:
+            ip = ''
+            raise Warning(
+                'AXES: Axes is configured for operation behind a '
+                'reverse proxy but received too few IPs in the HTTP '
+                'AXES_REVERSE_PROXY_HEADER. Check your '
+                'AXES_NUM_PROXIES configuration. '
+                'Header name: {0}, value: {1}'.format(
+                    REVERSE_PROXY_HEADER, ip_str
+                )
+            )
 
         if not ip:
             raise Warning(
-                'AXES: Axes is configured for operation behind a reverse proxy '
-                'but could not find an HTTP header value. Check your proxy '
-                'server settings to make sure this header value is being '
-                'passed. Header name {0}'.format(REVERSE_PROXY_HEADER)
+                'AXES: Axes is configured for operation behind a reverse '
+                'proxy but could not find a suitable IP in the specified '
+                'HTTP header. Check your proxy server settings to make '
+                'sure correct headers are being passed to Django in '
+                'AXES_REVERSE_PROXY_HEADER. '
+                'Header name: {0}, value: {1}'.format(
+                    REVERSE_PROXY_HEADER, ip_str
+                )
             )
 
         return ip
diff --git a/runtests_num_proxies.py b/runtests_num_proxies.py
new file mode 100644
--- /dev/null
+++ b/runtests_num_proxies.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python
+
+from runtests import run_tests
+
+if __name__ == '__main__':
+    run_tests('axes.test_settings_num_proxies', [
+        'axes.tests.GetIPNumProxiesTest',
+    ])


Test patch:
diff --git a/axes/test_settings_num_proxies.py b/axes/test_settings_num_proxies.py
new file mode 100644
--- /dev/null
+++ b/axes/test_settings_num_proxies.py
@@ -0,0 +1,5 @@
+from .test_settings import *
+
+AXES_BEHIND_REVERSE_PROXY = True
+AXES_REVERSE_PROXY_HEADER = 'HTTP_X_FORWARDED_FOR'
+AXES_NUM_PROXIES = 2
diff --git a/axes/tests.py b/axes/tests.py
--- a/axes/tests.py
+++ b/axes/tests.py
@@ -538,3 +538,33 @@ def test_custom_header_parsing(self):
         for header in valid_headers:
             self.request.META[settings.AXES_REVERSE_PROXY_HEADER] = header
             self.assertEqual(self.ip, get_ip(self.request))
+
+class GetIPNumProxiesTest(TestCase):
+    """Test that get_ip returns the correct last IP when NUM_PROXIES is configured
+    """
+
+    def setUp(self):
+        self.request = MockRequest()
+
+    def test_header_ordering(self):
+        self.ip = '2.2.2.2'
+
+        valid_headers = [
+            '4.4.4.4, 3.3.3.3, 2.2.2.2, 1.1.1.1',
+            '         3.3.3.3, 2.2.2.2, 1.1.1.1',
+            '                  2.2.2.2, 1.1.1.1',
+        ]
+
+        for header in valid_headers:
+            self.request.META[settings.AXES_REVERSE_PROXY_HEADER] = header
+            self.assertEqual(self.ip, get_ip(self.request))
+
+    def test_invalid_headers_too_few(self):
+        self.request.META[settings.AXES_REVERSE_PROXY_HEADER] = '1.1.1.1'
+        with self.assertRaises(Warning):
+            get_ip(self.request)
+
+    def test_invalid_headers_no_ip(self):
+        self.request.META[settings.AXES_REVERSE_PROXY_HEADER] = ''
+        with self.assertRaises(Warning):
+            get_ip(self.request)