Checkout Bouncer

Notes

REMOTE_ADDR, X-Forwarded-For, and why an IP allowlist can be spoofed

X-Forwarded-For is a header the client writes. Here is why trusting it turns an IP allowlist or rate limit into a bypass, and how to fix it.

5 min read

You add your office IP to an allowlist so staff can test checkout without friction. A week later, a bot walks through the same door. It did not guess your password or find a bug. It sent one extra header that said it was you.

This happens because PHP gives you more than one way to read a client IP, and only one of them comes from the network. The rest come from the request, and the sender writes the request.

Where REMOTE_ADDR comes from

$_SERVER['REMOTE_ADDR'] holds the address of the machine on the other end of the TCP connection. The web server fills it in from the socket, not from anything the client typed. A client cannot change it by adding a header, because the value never passes through the headers at all.

That makes REMOTE_ADDR hard to fake, but it does not make it the visitor. It is the last hop. If a proxy, load balancer, or CDN sits in front of your server, REMOTE_ADDR is the address of that proxy.

Where X-Forwarded-For comes from

X-Forwarded-For is an ordinary HTTP request header. Proxies append the address they received a connection from, so after a few hops it can hold a comma-separated chain. PHP exposes it as $_SERVER['HTTP_X_FORWARDED_FOR'].

Nothing stops a client from sending that header itself. Any HTTP library can do it in one line:

curl -X POST https://example.com/?wc-ajax=checkout \
  -H "X-Forwarded-For: 203.0.113.10" \
  --data "..."

If your code trusts the first value in that header, the request now claims to come from 203.0.113.10. The same goes for X-Real-IP, Client-IP, True-Client-IP, and CF-Connecting-IP when they reach your server from somewhere other than the proxy that is supposed to set them.

Why plugins read the header anyway

Plugin authors read forwarded headers because many hosts put a proxy in front of PHP. Without the header, every visitor looks like the same handful of proxy addresses. Rate limits then lump all customers together, and geolocation points at the data center.

So a common helper checks a list of headers first and falls back to REMOTE_ADDR. WooCommerce's own geolocation helper works this way. For geolocation, a spoofed value only means a wrong tax or shipping guess. For a security decision, it means the attacker picks the answer.

How an allowlist turns into a bypass

Put the pieces together. Your security plugin skips checks for addresses on an allowlist. It reads the client IP from X-Forwarded-For when that header exists. An attacker sends the header with an address from your allowlist, or with any address at all if they only want to dodge a per-IP rate limit.

The attacker does not even need to know your allowlist for the second trick. Rotating a fake header value on every request gives each attempt a fresh identity, and a throttle that counts per IP never fills up.

Office IPs are not secret either. They show up in email headers, in old support tickets, and in DNS for self-hosted services. Treat an allowlist entry as something a determined person could learn.

The safe rule

Trust a forwarded header only when the request came from a proxy you control or pay for. Check REMOTE_ADDR first. If it belongs to your proxy's published ranges, read the header that proxy sets. If it does not, ignore every forwarded header and use REMOTE_ADDR.

The best place to do this is the web server, not each plugin. When the server rewrites REMOTE_ADDR to the real visitor address, every plugin on the site gets the right value without trusting any header on its own.

Nginx behind Cloudflare

The ngx_http_realip_module handles this. You list the proxy ranges you trust and name the header to read:

# repeat for each published cloudflare range
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 2400:cb00::/32;
real_ip_header CF-Connecting-IP;

Cloudflare publishes its current IPv4 and IPv6 ranges, and they change from time to time. Keep the list in a file you can update, not pasted by hand into one server block and forgotten.

Apache behind a proxy

Apache uses mod_remoteip for the same job:

RemoteIPHeader CF-Connecting-IP
# repeat for each trusted proxy range
RemoteIPTrustedProxy 173.245.48.0/20

If your host manages the server, ask them whether they already restore the real IP. Many managed WordPress hosts do this at their edge, and then REMOTE_ADDR already holds the visitor address when PHP sees it.

How to check what your site sees

Drop a short test file on a staging copy, or log the values from a must-use plugin for a few requests:

<?php
// staging only, delete after
header('Content-Type: text/plain');
echo 'REMOTE_ADDR: ' . ($_SERVER['REMOTE_ADDR'] ?? '') . "\n";
echo 'X-Forwarded-For: ' . ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '') . "\n";
echo 'CF-Connecting-IP: ' . ($_SERVER['HTTP_CF_CONNECTING_IP'] ?? '') . "\n";

Load it from your phone on mobile data. If REMOTE_ADDR shows your phone's public address, the server already restores it and you can leave headers alone. If REMOTE_ADDR shows a proxy range, fix the server configuration before you build anything on client IPs.

Then send the same request with a made-up X-Forwarded-For using curl. If any security tool on the site reports that fake address as the client, you found the hole.

What an allowlist is still good for

An allowlist is fine for convenience, as long as the IP it checks comes from the socket or from a proxy you trust. Keep it short, use CIDR ranges you own, and remove entries when staff move or an office changes provider.

For staff testing, a logged-in role check is usually stronger than an IP check. A session cookie takes more effort to steal than a header takes to type.

We built Checkout Bouncer to read the client IP from REMOTE_ADDR only. It reads a proxy header only when a site owner opts in with a filter, after they know which proxy sets it. Its allowlist and throttle both use that same value, so a forged header does not move either one.

See which doors into your checkout are standing open