X-Forwarded-For: per-IP rate limit the client controlled
BusiKM per-IP rate limits took the client IP from the first X-Forwarded-For entry, which the client sets. How to count it from the trusted proxy in Django.
Published: 5 min read
Author: Łukasz Głowacz, Full-Stack Engineer · Czytaj po polsku

Contents
Until October 7, every per-IP limit in BusiKM took the address from the first entry of the X-Forwarded-For header. That value is set by the client, which is exactly who the limit is supposed to restrict. Sending a different header with each request was enough to get a fresh counter every time. The fix is small, but it shows well that the client address behind a proxy is an infrastructure configuration matter, not an implementation detail.
How X-Forwarded-For grows
An application behind a reverse proxy doesn't see the user's address in REMOTE_ADDR, only the proxy's address. That's why the proxy passes the original address in the X-Forwarded-For header. This header grows from the left to the right: if the client sends it themselves, their entry stays at the beginning, and each subsequent proxy appends at the end the address it received the request from.

The old function in BusiKM looked like many examples on the internet:
def get_client_ip(request) -> str:
"""Extract client IP from request, handling reverse proxies."""
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()
return request.META.get("REMOTE_ADDR", "")Behind a proxy, the first entry is either the real address (when the client sent nothing) or whatever the client chose to write. The code had no way to tell the difference.
What could be bypassed
get_client_ip is a single function used by almost every limit in the backend. The test for the fix lists the four places that depended on it most:
- the login backstop: 30 attempts from one address per 15 minutes,
- the limit on sending password reset emails,
- the limit for anonymous requests in the global middleware,
- the limit on guessing invitation codes and reset codes, counted per email and address pair.
Not everything was open. Login has a second, independent limit: 5 attempts per email in 15 minutes, and that one doesn't look at the address. An attack on a single account was still stopped. What was open was what a per-IP limit protects against: many accounts from one machine, and code guessing, where the key included the address, so every forged header gave a new set of five attempts.
Count from the end, not from the start
You can only trust the client address at the point appended by a proxy you trust. If there is one such layer in front of the application, the client address is the last entry. If there are two, it's the second to last. Everything to the left of that point comes from the client and is worthless.
def get_client_ip(request) -> str:
# Count from the trusted side: with N trusted proxies, the client address
# is the entry added by the outermost one. Everything to the left of it
# was written by the client.
num_proxies = getattr(settings, "TRUSTED_PROXY_COUNT", 0)
remote_addr = request.META.get("REMOTE_ADDR", "")
if num_proxies <= 0:
return remote_addr # no proxy: the header is ignored completely
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
hops = [hop.strip() for hop in forwarded.split(",") if hop.strip()]
if len(hops) < num_proxies:
# Shorter than our proxies would make it, so they did not write it.
return remote_addr
return hops[-num_proxies]The number of trusted layers is the TRUSTED_PROXY_COUNT setting, read from an environment variable. The default is zero, meaning the header is ignored and only REMOTE_ADDR counts, which the client doesn't control. Environments exposed behind a proxy raise it to the number of their layers. I chose a safe default: if someone runs the app without a proxy and forgets the setting, a forged header changes nothing. The opposite mistake, running behind a proxy with zero in the setting, ends with all users sharing the proxy's address and one counter. That kind of mistake surfaces quickly, because the limit starts blocking everyone at once. A hole in a limit is not visible at all.
There is one more detail. DRF has its own address resolution for throttling, controlled by the NUM_PROXIES setting. Without that setting, DRF uses the entire X-Forwarded-For header as the identifier, so a forged header again gives a new counter. I set NUM_PROXIES to the same value as TRUSTED_PROXY_COUNT, so that DRF's limits and mine count the same address. The logic isn't identical: when the chain is shorter than the number of proxies, DRF takes the first entry, while my function falls back to REMOTE_ADDR. That's why BusiKM's own limits go through get_client_ip.
A test that checks the point, not the implementation
There are several unit tests: without a proxy the header is ignored, behind one layer the last entry wins, behind two the second to last, and a chain that is too short falls back to REMOTE_ADDR. The most important one, though, is the one that describes the goal of the whole change:
def test_spoofing_does_not_change_the_throttle_key(request_factory, settings):
"""A different header on every request must not give a new counter."""
settings.TRUSTED_PROXY_COUNT = 1
keys = {
get_client_ip(
request_factory.get(
"/",
REMOTE_ADDR=PROXY_ADDR,
HTTP_X_FORWARDED_FOR=f"203.0.113.{n}, {REAL_CLIENT}",
)
)
for n in range(1, 25)
}
assert keys == {REAL_CLIENT}24 requests with different forged entries must produce a single key. This test doesn't know how the position in the chain is calculated, so it survives any change of implementation that preserves what matters.
How many proxy layers there really are
The code assumes a number of layers, but you can't read that number from the code. It's a fact about infrastructure, so I check it on a running environment, not in the settings. On staging, the API responds directly from Railway, with no intermediary in front of it, and the TRUSTED_PROXY_COUNT variable isn't set in the environment, so the one layer from the settings applies. That checks out.
That number stops being true the moment someone puts another intermediary in front of the application, for example a CDN or a WAF acting as a proxy. Then you either have to raise the number of layers or read the header that this intermediary sets with the client address. So every change to what stands in front of the application should end with a check of which address Django sees.
In the previous post I wrote about an exception list that drifted apart between the backend and the frontend. Here is a similar problem in a different place: an assumption about infrastructure lives in the application settings, while the infrastructure changes on its own. If you have a similar setup and are wondering how to keep it in check, write to me.
