The login form
had a sensible guard.
Five failed attempts
per IP address per minute.
After that,
wait.
The app ran behind a load balancer,
so every connection
came from the balancer's own address.
Every user would have shared
one limit.
Somebody fixed that
with one line.
app.set('trust proxy', true)
Now req.ip
showed the real client.
The logs looked right.
The tests passed.
Months later
the security team found
forty thousand guesses
against one account
in a single night.
The limiter
had not blocked one of them.
Every request
had come from a different IP.
None of those IPs
had sent anything.
Here is the mechanism.
A load balancer
tells the app who connected
by adding a header.
X-Forwarded-For.
If the request
already has one,
the balancer does not replace it.
It appends
the address it actually saw
to the end.
So the header is a list.
On the right,
what your own proxy wrote.
On the left,
whatever the client claimed.
With trust proxy set to true,
Express takes the left-most entry
as the client's address.
The part the attacker typed.
The attacker sent
a fresh random address
with every guess,
and every guess
got a fresh limit of five.
The header was never lying
about the part you control.
You were reading the other end.
The fix is to count hops,
not to trust headers.
Tell the framework
how many proxies you run.
app.set('trust proxy', 1)
Now it walks the list
from the right,
and stops at the address
your own balancer recorded.
Other stacks
have the same switch
under a different name.
Nginx calls it real_ip,
and wants to know
which addresses are yours.
Then test it the way an attacker would.
Send a request
with a forged X-Forwarded-For,
and check which address
your limiter recorded.
Rate limit the account as well,
not only the address.
Any value a client can send
is a claim.
Only what your own infrastructure
wrote down
is a fact.
– Serguey Asael Shinder

