Symfony Login Throttling Behind a Reverse Proxy: Without trusted_proxies, Everyone Shares One IP

By Lars Nielsen ·

CaRMen, the CRM I built for myself, now runs as two public sites: the real instance and a demo. Public means bots find the login endpoint within hours, so in October I added rate limiting to logins and to password-reset emails. The limits themselves took a few lines of Symfony configuration. The part worth writing about is the line without which those limits would have made things worse: telling Symfony which proxies to trust.

The limits

Login throttling is built into Symfony's security component. In security.yaml, on the firewall that handles the JSON login:

  • login_throttling:
  • max_attempts: 5
  • interval: '15 minutes'

That one setting creates two limiters, which I only understood after reading the bundle's source. The first counts failed attempts per username and IP address together, and allows max_attempts. The second counts per IP address alone, and its limit is five times max_attempts, so 25 here. A successful login resets the counters. One detail surprised me when I wrote the tests: a throttled login still answers 401, the same status as a wrong password, with "Too many failed login attempts" in the message.

Password reset needed its own limiter: login_throttling only counts login attempts, and the reset endpoint is an ordinary controller that sends email. That's a sliding_window limiter allowing three requests per address per hour, and the controller returns 429 when it's used up. The check runs on the lowercased, trimmed address before the user lookup. The endpoint already answered 200 whether the address existed or not, so nobody could use it to find out which accounts exist; checking the limit first keeps the 429 from leaking that either.

Every visitor had the same IP

Both limiters count per IP address, and in production the application never sees the visitor's address. A request passes the host's nginx and then the nginx inside the web container, before it reaches PHP. To Symfony, the client is whichever proxy connected last: a private address on the Docker network, the same one for every request.

With per-IP limits in place, that's worse than no limits. The 25-per-IP limiter would have counted everyone's failed logins together, so one bot guessing passwords could lock out every user of the site. The per-account limiter has the same problem in a smaller way: five failed attempts on your account from anywhere would lock you out too, because every attempt looks like it came from the same place.

The fix is to tell Symfony which proxies it can believe, in framework.yaml:

  • trusted_proxies: '%env(TRUSTED_PROXIES)%'
  • trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-port']

The production compose file sets TRUSTED_PROXIES: private_ranges. That's a shortcut Symfony expands to its list of private and reserved networks: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, loopback, link-local and a few more. Both nginx hops sit on such addresses, so both count as trusted. In development the variable is empty, and Symfony trusts nobody.

Why a forged header doesn't help an attacker

The obvious worry about trusting X-Forwarded-For is that anyone can send one. A bot could write a different made-up address into it on every attempt and never hit the per-IP limit.

That doesn't work, and the reason is in how Symfony reads the header. It takes the address list from X-Forwarded-For, appends the address the request came from, drops every entry that falls inside a trusted range, and reverses what's left. The client IP is the first entry of that reversed list: the rightmost address that isn't a trusted proxy. So as long as the outermost proxy appends the address of the connection it received to the header, whatever the client wrote sits to the left of that address and never becomes the client IP.

That depends on the proxy configuration, not just on Symfony, so I tested it behind a production-like proxy setup: a spoofed X-Forwarded-For didn't get around the limit, and other IPs and other accounts were unaffected while one account was locked.

Tests that pin it down

Two tests went in with the change. One fails a login five times on a fresh address, checks that none of those responses mention throttling, and checks that the sixth one does. The other fails six logins for one account and then logs in as the admin from the same client, which must still work: throttling one account mustn't take the others down with it.

The login and forgot-password pages also show the "too many attempts" messages now, so a real user who hits the limit sees why.

The general lesson is short. Anything in an application that keys on the client IP (rate limits, logs, audit trails) is only as good as the trusted-proxy setting behind it. Behind a reverse proxy, the default is that every visitor is the proxy.

Filed under

Leave a comment

Comments are reviewed before they appear.

Basic HTML allowed: links, emphasis, code and lists. Web addresses become links automatically.