X-Forwarded-For client IP selection

1. The problem I’m having:

I want my wordpress instance to see the real client IP instead of local IP of my varnish server : 127.0.0.1
Here is the operating diagram of my installation :
Client → 80/443 port → Caddy reverse proxy → 6081 → Varnish server → port number depends of backend → caddy webserver

2. Error messages and/or full log output:

Instead of only seeing the client IP, I have a suit of IP containing local IP :

IP FORWARD XXX.XXX.XXX, 127.0.0.1, 127.0.0.1, 127.0.0.1

XXX.XXX.XXX : is the real client IP

3. Caddy version:

v2.7.6 h1:w0NymbG2m9PcvKWsrXO6EEkY9Ru4FJK8uQbYcev1p3A=

4. How I installed and ran Caddy:

a. System environment:

Rocky Linux 9

b. Command:

sudo systemctl enable caddy
sudo systemctl start caddy

d. My complete Caddy config:

My principal caddyfile :

{
	servers {
		trusted_proxies static private_ranges
		client_ip_headers X-Forwarded-For X-Real-IP
	}
}
import Caddyfile.d/*.caddyfile

My caddyfile for domaine.fr

# Domaine test.domaine.fr
test.domaine.fr {
    # Port où Varnish écoute les requêtes
	reverse_proxy 127.0.0.1:6081
}

:8060 {
	root * /srv/www/test
    file_server
    php_fastcgi unix//run/php-fpm/www.sock
    encode zstd gzip
}

5. Links to relevant resources:

Here is varnishlog where we can see that Varnish recieve HTTP Header from caddy reverse proxy with only the client IP and then Varnish add multiple times the 127.0.01 IP adress.
How to tell to caddy web server to only select the most right non-local IP ?

[rocky@vps ~]$ varnishlog
*   << Request  >> 885930    
-   Begin          req 885929 rxreq
-   Timestamp      Start: 1715532826.302347 0.000000 0.000000
-   Timestamp      Req: 1715532826.302347 0.000000 0.000000
-   VCL_use        boot
-   ReqStart       127.0.0.1 38290 a0
-   ReqMethod      GET
-   ReqURL         /realip.php
-   ReqProtocol    HTTP/1.1
-   ReqHeader      Host: test.domaine.fr
-   ReqHeader      User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36
-   ReqHeader      Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
-   ReqHeader      Accept-Encoding: gzip, deflate, br, zstd
-   ReqHeader      Accept-Language: fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7
-   ReqHeader      Cache-Control: max-age=0
-   ReqHeader      Cookie: _ga=GA1.1.294261596.1572729531; opcachegui=true; _ga_T9DB9ZC6V0=GS1.1.1715473440.105.1.1715473462.0.0.0
-   ReqHeader      Dnt: 1
-   ReqHeader      Sec-Ch-Ua: "Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"
-   ReqHeader      Sec-Ch-Ua-Mobile: ?0
-   ReqHeader      Sec-Ch-Ua-Platform: "Windows"
-   ReqHeader      Sec-Fetch-Dest: document
-   ReqHeader      Sec-Fetch-Mode: navigate
-   ReqHeader      Sec-Fetch-Site: none
-   ReqHeader      Sec-Fetch-User: ?1
-   ReqHeader      Upgrade-Insecure-Requests: 1
-   ReqHeader      X-Forwarded-For: XXX.XXX.XXX.XXX
-   ReqHeader      X-Forwarded-Host: test.domaine.fr
-   ReqHeader      X-Forwarded-Proto: https
-   ReqUnset       X-Forwarded-For: XXX.XXX.XXX.XXX
-   ReqHeader      X-Forwarded-For: XXX.XXX.XXX.XXX, 127.0.0.1
-   VCL_call       RECV
-   ReqUnset       X-Forwarded-For: XXX.XXX.XXX.XXX, 127.0.0.1
-   ReqHeader      x-forwarded-for: XXX.XXX.XXX.XXX, 127.0.0.1, 127.0.0.1
-   ReqUnset       Host: test.domaine.fr
-   ReqHeader      host: test.domaine.fr
-   ReqURL         /realip.php
-   ReqUnset       Cookie: _ga=GA1.1.294261596.1572729531; opcachegui=true; _ga_T9DB9ZC6V0=GS1.1.1715473440.105.1.1715473462.0.0.0
-   VCL_return     hash
-   ReqUnset       Accept-Encoding: gzip, deflate, br, zstd
-   ReqHeader      Accept-Encoding: gzip
-   VCL_call       HASH
-   VCL_return     lookup
-   Hit            623667 3578.574584 10.000000 0.000000
-   VCL_call       HIT
-   VCL_return     deliver
-   RespProtocol   HTTP/1.1
-   RespStatus     200
-   RespReason     OK
-   RespHeader     Content-Encoding: gzip
-   RespHeader     Content-Type: text/html; charset=UTF-8
-   RespHeader     Server: Caddy
-   RespHeader     Vary: Accept-Encoding
-   RespHeader     X-Powered-By: PHP/8.2.18
-   RespHeader     Date: Sun, 12 May 2024 16:53:24 GMT
-   RespHeader     Content-Length: 108
-   RespHeader     x-url: /realip.php
-   RespHeader     x-host: test.domaine.fr
-   RespHeader     X-Cacheable: YES:Forced
-   RespHeader     X-Varnish: 885930 623667
-   RespHeader     Age: 21
-   RespHeader     Via: 1.1 varnish (Varnish/6.6)
-   VCL_call       DELIVER
-   RespUnset      x-url: /realip.php
-   RespUnset      x-host: test.domaine.fr
-   VCL_return     deliver
-   Timestamp      Process: 1715532826.302429 0.000082 0.000082
-   Filters        
-   RespHeader     Accept-Ranges: bytes
-   RespHeader     Connection: keep-alive
-   Timestamp      Resp: 1715532826.302463 0.000116 0.000034
-   ReqAcct        946 0 946 343 108 451
-   End       

Are those on the same Caddy instance or are those separate Caddy servers?

Asking because it’s unclear with the import what’s running where.

Enable the debug global option in Caddy and look at the proxy logs. Look at the X-Forwarded-For header and see how it’s passed through. How does it look?

1 Like

Yes they are both running on the same caddy server.

Here is my debug log :

May 13 14:36:58 vps systemd[1]: Started Caddy.
May 13 14:37:14 vps caddy[3626004]: {"level":"debug","ts":1715603834.0703733,"logger":"events","msg":"event","name":"tls_get_certificate","id":"e98a6e31-5e0c-4d8b-978f-07750d95c2b0","origin":"tls","data":{"client_hello":{"CipherSuites":[4865,4866,4867,49195,49199,49196,49200,52393,52392,49171,49172,156,157,47,53,255],"ServerName":"test.domaine.fr","SupportedCurves":[29,23,24],"SupportedPoints":"AAEC","SignatureSchemes":[1027,2052,1025,1283,2053,1281,2054,1537],"SupportedProtos":["h2","http/1.1"],"SupportedVersions":[772,771,770,769,768],"RemoteAddr":{"IP":"XXX.XXX.XXX.XXX","Port":65499,"Zone":""},"LocalAddr":{"IP":"51.195.40.105","Port":443,"Zone":""}}}}
May 13 14:37:14 vps caddy[3626004]: {"level":"debug","ts":1715603834.0705206,"logger":"tls.handshake","msg":"choosing certificate","identifier":"test.domaine.fr","num_choices":1}
May 13 14:37:14 vps caddy[3626004]: {"level":"debug","ts":1715603834.0705378,"logger":"tls.handshake","msg":"default certificate selection results","identifier":"test.domaine.fr","subjects":["test.domaine.fr"],"managed":true,"issuer_key":"acme-v02.api.letsencrypt.org-directory","hash":"bc837f55af80e36978d741544418044110ac127960520e0905d30bee175c8f3c"}
May 13 14:37:14 vps caddy[3626004]: {"level":"debug","ts":1715603834.0705462,"logger":"tls.handshake","msg":"matched certificate in cache","remote_ip":"XXX.XXX.XXX.XXX","remote_port":"65499","subjects":["test.domaine.fr"],"managed":true,"expiration":1720224285,"hash":"bc837f55af80e36978d741544418044110ac127960520e0905d30bee175c8f3c"}
May 13 14:37:14 vps caddy[3626004]: {"level":"debug","ts":1715603834.1487696,"logger":"http.handlers.reverse_proxy","msg":"selected upstream","dial":"127.0.0.1:6081","total_upstreams":1}
May 13 14:37:14 vps caddy[3626004]: {"level":"debug","ts":1715603834.1494346,"logger":"http.handlers.reverse_proxy","msg":"upstream roundtrip","upstream":"127.0.0.1:6081","duration":0.000575837,"request":{"remote_ip":"XXX.XXX.XXX.XXX","remote_port":"65499","client_ip":"XXX.XXX.XXX.XXX","proto":"HTTP/2.0","method":"GET","host":"test.domaine.fr","uri":"/realip.php","headers":{"Priority":["u=0, i"],"Sec-Fetch-Site":["none"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["https"],"X-Forwarded-Host":["test.domaine.fr"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Upgrade-Insecure-Requests":["1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"],"Accept-Language":["fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7"],"Dnt":["1"],"Cache-Control":["max-age=0"],"Sec-Fetch-Dest":["document"],"Sec-Ch-Ua":["\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\""],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"],"Sec-Fetch-Mode":["navigate"],"Sec-Fetch-User":["?1"],"Cookie":[],"X-Forwarded-For":["XXX.XXX.XXX.XXX"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"test.domaine.fr"}},"headers":{"Age":["1288"],"Via":["1.1 varnish (Varnish/6.6)"],"Accept-Ranges":["bytes"],"Content-Encoding":["gzip"],"Vary":["Accept-Encoding"],"X-Cacheable":["YES:Forced"],"X-Varnish":["1639202 1180404"],"Content-Length":["83"],"Connection":["keep-alive"],"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.18"],"Date":["Mon, 13 May 2024 12:15:45 GMT"]},"status":200}

To see what IP are transmitted to PHP, I created the page realip.php with the following code :

<?php
echo "IP ".$_SERVER["REMOTE_ADDR"]."<br />";
echo "IP FORWARD ".$_SERVER['HTTP_X_FORWARDED_FOR']."<br />";
echo "Client IP :".$_SERVER['HTTP_CLIENT_IP'];
echo "Real IP :".$_SERVER['HTTP_X_REAL_IP'];
?>

And here is what is shown with the current configuration :

IP 127.0.0.1
IP FORWARD XXX.XXX.XXX.XXX, 127.0.0.1, 127.0.0.1, 127.0.0.1
Client IP :
Real IP :

I don’t see a problem then. Your app should parse HTTP_X_FORWARDED_FOR and grab the left-most entry (or the right-most non-local entry). Caddy guarantees that’ll be the original client IP.

1 Like

To do then I added this code in php :

// Correct client IP address fix
if ( isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { 
    $xff_ip = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] ); 
    $_SERVER['REMOTE_ADDR'] = $xff_ip[0]; 
}

But I read that this is not a secure solution.
What do you think about this ?

It’s perfectly secure as long as you know you’re using Caddy in front of it and that Caddy is properly configured to throw away untrusted X-Forwarded-For values.

What you can also do is add a condition checking if the original REMOTE_ADDR is not from a private IP, which would mean someone managed to send a request to your server directly bypassing Caddy.

Or you can parse IPs from the right instead (explode then iterate in reverse) and take the first non-private (or non-trusted) IP.

1 Like

Thanks for your answer, I replace my code by this one and now it works :

//**Fonction pour corriger le client IP **//
// Fonction pour vérifier si IP locale
function is_local_ip($ip) {
    return $ip === '127.0.0.1' || $ip === '::1';
}

// Fonction IP valide 
function is_valid_ip($ip) {
    return filter_var($ip, FILTER_VALIDATE_IP) !== false;
}

// Fonction pour mettre IP par default comme REMOTE_ADDR
$real_ip = $_SERVER['REMOTE_ADDR'];

if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
    // Séparation des IP de X-Forwarded-For par des virgules
    $xff_ips = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));

    // Analyser les IP de droite à gauche et sélectionner la première IP valide qui n est pas locale
    foreach (array_reverse($xff_ips) as $ip) {
        if (is_valid_ip($ip) && !is_local_ip($ip)) {
            $real_ip = $ip;
            break;
        }
    }
    $_SERVER['REMOTE_ADDR'] = $real_ip;
    $_SERVER['HTTP_CLIENT_IP'] = $real_ip;
}
//** FIN DE LA FONCTION**//
1 Like