Back to Feed
article 3m read

Login throttle let the caller pick its own name

A hardening commit keyed my rate limiter on an unchecked auth cookie read before any session validation, so a caller who changes the cookie lands in a fresh full bucket every time and the tightest login limits are the easiest to skip.

Article DepthPractice

Flow

Two-bucket decision path

  1. 1Address bucket

    Always applies, keyed on an address set by your own proxy.

  2. 2Credential route?

    Add the targeted account to the key and ignore any identity claim.

  3. 3Verified session?

    Only a validated identity may raise the allowance on ordinary routes.

  4. 4Bounded store

    Idle buckets expire and the key count has a hard cap.

The login throttle on one of my web apps counts requests per caller. After one hardening commit, the caller got to decide who it was.

Both front ends send every request through a gate that runs before any route code. The gate works out a caller key, takes one token from that key's bucket, and refuses when the bucket is empty. Login and similar credential routes get a far smaller allowance than the rest, because those are the ones people script against.

The key used to be the client address. The commit added a second path: if the request carried the auth cookie, the gate used a chunk of the cookie as the key and gave signed-in callers a bigger allowance. The intent is fair, since many real people sit behind one address and throttling by address alone punishes all of them for one.

The problem is where the gate runs. It runs early, and as far as I can see nothing before it checks the cookie, so the cookie is unchecked when it becomes a key:

if (cookie) return "member-" + firstPart(cookie);
return clientAddress(req);

A present cookie always beats the address and is never validated here. Change it per request and each request is a key the gate has never seen, which means a fresh bucket at full capacity. The same keying is on the login routes, so the tightest limit is the easiest to skip. That's read from the diffs, not tested.

Two nearby changes make it worse. The fallback store is an in-memory map with no expiry and no cap, so every distinct cookie leaves a record and leaks memory. And a missing shared store in production used to refuse traffic; now it continues on that map, while a store that errors still refuses. A broken store blocks everyone and a missing one lets everyone in.

The commit also swapped a fixed window for a token bucket, which is a real improvement, but it smooths each bucket. The hole is how many buckets one caller can reach: total rate is per-key rate times number of keys, and the keying left the second part unbounded.

If you're building one of these, a few things to hold to. Key anonymous traffic on the address your own proxy sets, never a header the client can send. Let a cookie raise the allowance only after the session is actually verified, not before. Key login routes on the address plus the targeted account and ignore any identity claim. Give the store idle-eviction and a key cap. And make "no shared store in production" a flag someone sets on purpose, so it shows up in review.

As far as I can see in that day's diffs, none of that was in place as of the last commit. Mine still built the key from a cookie nothing had checked.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward