Repository: saleor/saleor

Issue:
Which IP to retrieve from the environment variables
Currently we have this code to retrieve an IP:
https://github.com/mirumee/saleor/blob/35d8750ae07bddb57222f5fec3512c2788b56f5c/saleor/core/utils/__init__.py#L54-L58

Which is fine–apart the fact someone can inject any else than an IP.

The question is: should we really trust the `X-Forwarded-For` header? Shouldn't we let the web server handle this part and provide us the IP it believe is correct through `REMOTE_ADDR`?

For example, in nginx, we can get the environment variable `REALIP_REMOVE_ADDR` and thus leave the choice to the user of which IP(s) and header(s) are to be **trusted** and handle the IP **validation**. With the above snippet, we don't leave the choice to the user.

Another case, is when the user has their saleor workers behind a proxy, e.g. CloudFlare. The IP would be set from `CF-Connecting-IP` to `REALIP_REMOVE_ADDR` (nginx).

I believe we could do something like this:
```python
ip_headers = ["REALIP_REMOVE_ADDR", "WHATEVER_APACHE_USES", "REMOTE_ADDR"]

for header in ip_headers:
    ip = request.META.get(header, None)
    if ip and not valid_ip(ip):
        return ip
return None
```



Discussion:
What is the risk here? Proxies will append their own IP to the list so until there is a security risk involved, I'd rather err on the side of someone being able to spoof an IP (in which case we could also record the entire chain of IP addresses) than record incorrect addresses for legitimate users.
The only issue is when the user wants to set which IP to use. I cannot find an equivalent for Apache. But at least, we could support `REALIP_REMOVE_ADDR` from nginx so the user can provide the client's IP when the server is behind a proxy.
Saleor already unpacks `x-forwarded-for` so that case is covered.
Yes, but in the case of Cloudflare, for example, `X-Forwarded-For` is not used. Instead, it sets `CF-Connecting-IP` then in the nginx configuration, the IP is checked (whether this is actually a cloudflare IP). If valid, it gets stored into `REALIP_REMOVE_ADDR`.
CloudFlare docs suggest that it's supported: https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-

Resolving patch:
diff --git a/saleor/core/utils/__init__.py b/saleor/core/utils/__init__.py
--- a/saleor/core/utils/__init__.py
+++ b/saleor/core/utils/__init__.py
@@ -1,5 +1,6 @@
 import decimal
 import logging
+import socket
 from json import JSONEncoder
 from urllib.parse import urljoin
 
@@ -51,10 +52,38 @@ def build_absolute_uri(location):
     return iri_to_uri(location)
 
 
+def is_valid_ipv4(ip: str) -> bool:
+    """Check whether the passed IP is a valid V4 IP address."""
+    try:
+        socket.inet_pton(socket.AF_INET, ip)
+    except socket.error:
+        return False
+    return True
+
+
+def is_valid_ipv6(ip: str) -> bool:
+    """Check whether the passed IP is a valid V6 IP address."""
+    try:
+        socket.inet_pton(socket.AF_INET6, ip)
+    except socket.error:
+        return False
+    return True
+
+
 def get_client_ip(request):
-    ip = request.META.get("HTTP_X_FORWARDED_FOR", None)
-    if ip:
-        return ip.split(",")[0].strip()
+    """Retrieve the IP address from the request data.
+
+    Tries to get a valid IP address from X-Forwarded-For, if the user is hiding behind
+    a transparent proxy or if the server is behind a proxy.
+
+    If no forwarded IP was provided or all of them are invalid,
+    it fallback to the requester IP.
+    """
+    ip = request.META.get("HTTP_X_FORWARDED_FOR", "")
+    ips = ip.split(",")
+    for ip in ips:
+        if is_valid_ipv4(ip) or is_valid_ipv6(ip):
+            return ip
     return request.META.get("REMOTE_ADDR", None)
 
 


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -6,7 +6,7 @@
 import pytest
 from django.shortcuts import reverse
 from django.templatetags.static import static
-from django.test import Client, override_settings
+from django.test import Client, RequestFactory, override_settings
 from django.urls import translate_url
 from measurement.measures import Weight
 from prices import Money
@@ -19,6 +19,7 @@
     create_superuser,
     create_thumbnails,
     format_money,
+    get_client_ip,
     get_country_by_ip,
     get_country_name_by_code,
     get_currency_for_country,
@@ -67,6 +68,27 @@ def test_get_country_by_ip(ip_data, expected_country, monkeypatch):
     assert country == expected_country
 
 
+@pytest.mark.parametrize(
+    "ip_address, expected_ip",
+    [
+        ("83.0.0.1", "83.0.0.1"),
+        ("::1", "::1"),
+        ("256.0.0.1", "127.0.0.1"),
+        ("1:1:1", "127.0.0.1"),
+        ("invalid,8.8.8.8", "8.8.8.8"),
+        (None, "127.0.0.1"),
+    ],
+)
+def test_get_client_ip(ip_address, expected_ip):
+    """Test providing a valid IP in X-Forwarded-For returns the valid IP.
+    Otherwise, if no valid IP were found, returns the requester's IP.
+    """
+    expected_ip = expected_ip
+    headers = {"HTTP_X_FORWARDED_FOR": ip_address} if ip_address else {}
+    request = RequestFactory(**headers).get("/")
+    assert get_client_ip(request) == expected_ip
+
+
 @pytest.mark.parametrize(
     "country, expected_currency",
     [(Country("PL"), "PLN"), (Country("US"), "USD"), (Country("GB"), "GBP")],