Improving nginx integration with CloudFlare

Using CloudFlare for its firewall capabilities has undeniable benefits, but it’s not as foolproof and transparent as one may think. In this post I’ll explain how to configure nginx on your server for better integration and security.

Prior experience with CloudFlare and some knowledge of nginx configuration is assumed.

Obtaining the visitor IP

When you set CloudFlare to act as a proxy, the remote address becomes one of CloudFlare’s IPs and it gets everywhere – in log files and applications (e.g PHP's $_SERVER['REMOTE_ADDR']). The original address is placed in two headers, CF-Connecting-IP and X-Forwarded-For. The difference between the two is that CF-Connecting-IP keeps the original client IP, while X-Forwarded-FOR keeps a whole chain of IPs if there are multiple proxies.

For a while I relied on a small PHP function to get the original IP:

function proxy_safe_get_ip()
{
  $ip_keys = [
      'HTTP_CF_CONNECTING_IP',
      'HTTP_CLIENT_IP',
      'HTTP_X_FORWARDED_FOR',
      'HTTP_X_FORWARDED',
      'HTTP_X_CLUSTER_CLIENT_IP',
      'HTTP_FORWARDED_FOR',
      'HTTP_FORWARDED',
      'HTTP_X_FORWARDED_IP',
      'HTTP_X_REAL_IP',
      'REMOTE_ADDR'
    ];

  foreach ($ip_keys as $key)
  {
    if (array_key_exists($key, $_SERVER))
    {
      foreach (explode(',', $_SERVER[$key]) as $ip)
      {
        $ip = trim($ip);
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE))
          return $ip;
      }
    }
  }
  return '';
}

I intended to make this function work with all sorts of proxies, not just CloudFlare. The function looks for several headers, in order of preference. For each header, it takes the value and, if needed, splits it by a comma (useful for HTTP_X_FORWARDED_FOR) and gets the first valid IP. The last header to try is REMOTE_ADDR, so it defaults to that if nothing else is found.

This function served me well over the years, but you don’t always have control over the application.

Fortunately nginx has a very clever feature. You can designate a list of IPs as trusted proxies and nginx will write back the original IP for you.

So you can simply write in any http, server or location block:

set_real_ip_from xxx.xxx.xxx.xxx;
real_ip_header CF-Connecting-IP;

Disallowing direct connections

Just because CloudFlare acts as a proxy (and WAF) doesn’t mean your server IP is protected. An attacker could learn the origin IP in many ways. For example, Censys keeps a history of SSL certificates for domains and the IPs they were used for. It would make sense therefore to only allow connections from CloudFlare servers and disallow everything else. This is easily accomplished with:

allow xxx.xxx.xxx.xxx;
deny all;

There’s a problem here. If you also use the previous feature to get back to visitor IP, you no longer have the CloudFlare IPs but the real visitor IPs, so you can’t use allow/deny.

I spent quite some time on this issue. The solutions on the Internet were incomplete or, in some cases, plain wrong. After asking around and failing to find a single correct answer, I re-read the docs and saw that nginx places the original (proxy) IP in a variable called $realip_remote_addr.

The final piece of the puzzle is a rather obscure geo directive. It can be placed only in a http block and it can set a variable based on another on another one. It defaults to $remote_addr but other variables can be used.

Armed with this knowledge, we can write:

geo $realip_remote_addr $isCloudFlare 
{
  xxx.xxx.xxx.xxx 1;
  yyy.yyy.yyy.yyy 1;
  default 0;
}

Then, wherever you want to block connections not coming from CloudFlare, you’d write:

if ($isCloudFlare = 0) 
{
    return 403; 
}

Getting the list of CloudFlare IP and keeping them up-to-date

CloudFlare doesn’t seem to change the IPs very often, but it’s still a good practice to keep this list updated. You can manually get the list and include it, but we can do better:

#!/bin/bash

CF_REALIP=/etc/nginx/snippets/cf_realip.conf 
CF_ALLOWIP=/etc/nginx/snippets/cf_allowdeny.conf
CF_GEO=/etc/nginx/snippets/cf_geo.conf 

echo "#Cloudflare" > $CF_REALIP;
echo "#Cloudflare" > $CF_ALLOWIP;
echo "#Cloudflare" > $CF_GEO;

echo "default 0;" >> $CF_GEO;

for i in `curl https://www.cloudflare.com/ips-v4`; do
    echo "set_real_ip_from $i;" >> $CF_REALIP;
    echo "allow $i;" >> $CF_ALLOWIP;
    echo "$i 1;" >> $CF_GEO;
done

for i in `curl https://www.cloudflare.com/ips-v6`; do
    echo "set_real_ip_from $i;" >> $CF_REALIP;
    echo "allow $i;" >> $CF_ALLOWIP;
    echo "$i 1;" >> $CF_GEO;
done

echo "real_ip_header CF-Connecting-IP;" >> $CF_REALIP;

echo "allow 127.0.0.1;" >> $CF_ALLOWIP;
echo "allow ::1;" >> $CF_ALLOWIP;
echo "deny all;" >> $CF_ALLOWIP;

nginx -t && systemctl reload nginx

This script downloads the latest lists of IPv4 and IPv6 CloudFlare addresses and writes 3 config files for nginx in /etc/nginx/snippets: One for real_ip, one allow/deny and one for the geo directive. You can then include those files where you need them. Of course, you can also set up a cron job to run from time to time to update the IPs automatically.

Authenticated Origin Pull

While not directly related to the article, there is another way of disallowing direct connections and ensuring all connections to the server come from CloudFlare. Authenticated Origin Pull uses a more complex certificate-based approach than an IP whilelist and should be more secure but also more difficult.

Picture of Armand Niculescu

Armand Niculescu

Senior Full-stack developer and graphic designer with over 25 years of experience, Armand took on many challenges, from coding to project management and marketing.