How to safely get the IP address of the current user in a WordPress plugin

This article is based on outstanding research done in The perils of the “real” client IP.

If you are creating a WordPress plugin that relies in any way or shape on getting the real IP of the current user visiting the site you need to be extremely careful.

If you are getting this wrong (and probably you will) the consequences can be catastrophic depending on your use case.

If you are using the correct IP to do anything security-related you should read the above article at least three times (be prepared to go down a rabbit hole).

How plugins get it wrong

To this day I have not seen a single plugin that does this 100% right.

The following code represents what you will find in some form in most WordPress plugins.

function my_plugin_get_ip_insecure() :string
{
    $headers = [
        'HTTP_CF_CONNECTING_IP', // CloudFlare
        'HTTP_X_FORWARDED_FOR',  // AWS LB and other reverse-proxies
        'HTTP_X_REAL_IP',
        'HTTP_X_CLIENT_IP',
        'HTTP_CLIENT_IP',
        'HTTP_X_CLUSTER_CLIENT_IP',
    ];
    
    foreach ($headers as $header) {
        if (array_key_exists($header, $_SERVER)) {
            $ip = $_SERVER[$header];
            
            // This line might or might not be used.
            $ip = trim(explode(',', $ip)[0]);
            
            return $ip;
        }
    }
    
    return $_SERVER['REMOTE_ADDR'];
}

The code might look different, but the logic is always the same.

  1. Check if any of the “special” headers are present that Cloudflare or other reverse proxies might set.
    1. If it does, get the value of the header and split it at each comma.
    2. Return the leftmost “IP”.
  2. If no “special” header is set default to the value of $_SERVER['REMOTE_ADDR']

Amusingly, there is a function in WordPress Core called get_unsafe_client_ip which looks an awful lot like the code sample above. The naming and comments clearly indicate that the function is not suitable at all for anything close to being security-related. Yet somehow, everybody seems to miss that.

SECURITY WARNING: This function is NOT intended to be used in circumstances where the authenticity of the IP address matters. This does NOT guarantee that the returned address is valid or accurate, and it can be easily spoofed.

WordPress Core docs

Why is this vulnerable to IP spoofing?

Let’s go through a couple of scenarios:

We will use the following mu-plugin to debug the return value of
our my_plugin_get_ip_insecure() function.

First, let’s define some common constants so that you reproduce this locally.

  • The real user IP (ie. your IP) will be: 49.219.249.65
  • The ID we are trying to spoof will be: 66.249.66.67 (GoogleBot)
// wp-content/mu-plugins/debug-ip.php

/* function from above defined here */

$real_user_ip = '49.219.249.65';

$_SERVER['REMOTE_ADDR'] = $real_user_ip;

echo my_plugin_get_ip_insecure();
echo "\n";
exit();

1. A site without any reverse proxy

This is your typical NGINX/Apache stack without any reverse proxy in front of it. Directly accessible from the internet.

curl https://site-without-reverse-proxy.com
49.219.249.65

What happens if we run the same command but this time we add a custom header?
(Remember, anybody can add headers as they please, web servers don’t remove headers)

curl https://site-without-reverse-proxy.com -H "X-Forwarded-For: 66.249.66.67"
66.249.66.67

We successfully spoofed the IP of GoogleBot. This is very bad.

We get the same outcome if we replace “X-Forwarded-For” with any other header in my_plugin_get_insecure_ip().

2. A site behind a reverse-proxy

Now let’s assume that our site is behind an AWS load balancer which also uses the “X-Forwarded-Header”.

If a NORMAL USER visits the site the value of the header will be:

X-Forwarded-For: <real-ip> <aws-load-balancer-ip>

To simulate this locally we add the following code to our mu-plugin.

+ $aws_load_balancer_ip = '8.165.147.203';
+ 
+ $_SERVER['HTTP_X_FORWARDED_FOR'] = isset($_SERVER['HTTP_X_FORWARDED_FOR'])
+    ? $_SERVER['HTTP_X_FORWARDED_FOR'].", $real_user_ip,  $aws_load_balancer_ip"
+    : "$real_user_ip, $aws_load_balancer_ip";

echo my_plugin_get_ip_insecure();
echo "\n";
exit();

}

For a normal user everything still works:

curl https://site-without-reverse-proxy.com
49.219.249.65

But what happens if we add a custom header in this scenario?

curl https://site-without-reverse-proxy.com -H "X-Forwarded-For: 66.249.66.67"
66.249.66.67

The IP-Adress is still spoofed.

The reason for this is that the X-Forwarded-For Header will now look like this once it hits PHP. Each reverse proxy appends headers to the already existing header. Not doing so would violate the HTTP spec.

X-Forwarded-For: <spoofed-ip> <real-ip> <aws-load-balancer-ip>

In our function, we are always reading the leftmost IP under the assumption that nobody will ever provide a spoofed IP to our reverse proxy.

$ip = trim(explode(',', $ip)[0]);

Even worse,

what happens if instead of the X-Forwarded-For header we now use CF-Connecting-IP?

Our function checks for the headers in a deterministic order.
There will always be some header that is checked first and if you are unlucky it’s not one that your reverse proxy is using, which means that anybody can spoof it.

function my_plugin_get_ip_insecure() :string
{
    $headers = [
        'HTTP_CF_CONNECTING_IP', // CloudFlare
        'HTTP_X_FORWARDED_FOR',  // AWS LB and other reverse-proxies
        'HTTP_X_REAL_IP',
        'HTTP_X_CLIENT_IP',
        'HTTP_CLIENT_IP',
        'HTTP_X_CLUSTER_CLIENT_IP',
    ];
    
    foreach ($headers as $header) {
        if (array_key_exists($header, $_SERVER)) {
            $ip = $_SERVER[$header];
            
            // This line might or might not be used.
            $ip = trim(explode(',', $ip)[0]);
            
            return $ip;
        }
    }
    
    return $_SERVER['REMOTE_ADDR'];
}
curl https://site-without-reverse-proxy.com -H "CF-Connecting-IP: 66.249.66.67"
66.249.66.67

Spoofed again.

I’ll stop giving different edge cases here since there is no point in recreating all the ones mentioned in The perils of the “real” client IP.

But, the two scenarios shown above are the most trivial. There are dozens of different edge cases and attack vectors to abuse this.

Consequences of IP spoofing in a WordPress plugin

This highly depends on how your plugin is using the retrieved IP.

If you are developing a security plugin the consequences can be catastrophic and cause existential danger to the businesses that use your plugin.

Let’s say that you are using the IP to rate limit login attempts.

If an attacker is able to feed you any IP he wants and then submits too many failed login requests your plugin will ban the attacker-controlled IP.

An attacker could:

  • Block search engine crawlers to get you delisted.
  • Block the site’s own reverse proxy
  • Block legitimate users by IP causing a DOS on the site.
  • Flood the database with the entire range of IPv6 addresses bringing down the MySQL server
  • Bypass a WAF

If on the other hand, your plugin uses the IP for NON-SECURITY related use cases IP-spoofing MIGHT ONLY lead to corrupted data.

This brings us to…

How to safely get the IP with a PHP function without being vulnerable to IP spoofing

You cannot. It’s simply not possible inside a WP plugin. IP detection is ALWAYS bound to the environment where the plugin runs.

You would need to give your users an insane amount of configuration options and they would need to have the knowledge to configure your plugin correctly. (Which they don’t)

And even then, if you are able to detect the IP 100% correctly (which I’m yet to see a plugin achieve) everything will break if the user that is using your plugin switches hosting environments.

Example:

Host A uses a stack of AWS LB ==> NGINX ==> PHP.
Your plugin is configured to work in this environment.

Host B uses a stack of AWS LB ==> VARNISH ==> NGINX ==> PHP.

If the user switches from HOST A ==> HOST B you will now be banning your own Varnish reverse proxy.

It might be possible to implement IP detection correctly in userland code IF you control the environment. With WordPress plugins this is NEVER the case.

The only thing you can rely on is that AN ATTACKER CAN NEVER SPOOF:

$ip = $_SERVER['REMOTE_ADDR']; // This will NEVER EVER be a spoofed IP. It might be a reverse proxy at worst.

It is the responsibility of the web hosting company to correctly transform any request headers so that $_SERVER[‘REMOTE_ADDR’] is always set to the original (“real”) client IP and not a reverse proxy.
The web host knows their stack. Your plugin does not.

Denying that fact means you will be wide open.

In fact, even leaving the security aspect aside, if a web host does not transform proxy IPs to REMOTE_ADDR WordPress will stop working correctly.

WordPress does not work natively behind a reverse proxy. You have to make it work.

Just look at how often Core and popular plugins access $_SERVER['REMOTE_ADDR']

WordPress core
WooCommerce
Jetpack
FluentForms
FluentCRM

If the web host does not make the conversion of the REMOTE_ADDR all of this functionality stops working.

The final solution

  1. ALWAYS use the function below to access the IP in your code.
/**
 * @return non-empty-string
 *
 * @throws MyPluginBrokenEnvironment
 */
function my_plugin_get_secure_ip() : string {
    
    if(
        ! isset($_SERVER['REMOTE_ADDR'])
        || ! is_string($_SERVER['REMOTE_ADDR'])
        || '' === $_SERVER['REMOTE_ADDR']
    ){
        throw new MyPluginBrokenEnvironment('Your webserver is misconfigured. REMOTE_ADDR is not set.');
    }

    // Don’t bother with validating that $_SERVER['REMOTE_ADDR'] is a valid IP.
    // If a user cannot trust his webserver to correctly set the REMOTE_ADDR he is in much bigger problems.
    return $_SERVER['REMOTE_ADDR'];
}

2. Educate your users that they need a proper WordPress host that does stuff right.

3. Implement something in your UI to validate that the host is doing it right AND refuse to bootstrap your plugin if it’s not done right.

  • Tell the user to visit a site like https://ipaddress.my/ to get his IP.
  • Create a “Compare IP” form to make an ajax request to an endpoint of your plugin which just returns the value of my_plugin_get_secure_ip.
  • Make the user confirm that both IPs match up.
  • Make the user confirm that he will repeat this process IF the host is switched.

Happy coding.

2 responses

  1. Hi Calvin,

    Impressive information and good challenge! Are you saying that the IP detection logic from the Wordfence plugin with 4+ million active installations is not correct? They use not only “REMOTE_ADDR”.

    Ref: https://plugins.svn.wordpress.org/wordfence/trunk/views/dashboard/option-howgetips.php