fix(link): address 4 codex [P2] findings on the rate limiter

All four were real bypass or fail-open paths on an endpoint about to be
publicly exposed:

- X-Forwarded-For was trusted from ANY peer. The origin also listens on the
  tailnet, so anyone reaching it directly could pick -- and rotate -- their
  own rate-limit key by sending a header. Now honored only when the socket
  peer is in a configured trusted_proxies list, and the last hop must parse
  as a real IP. trust_forwarded_for without trusted_proxies REFUSES startup.
- Capacity eviction was fail-open and exploitable: an attacker able to mint
  many distinct keys could evict their own live window and start fresh. Now
  reclaims only EXPIRED windows and refuses the new key when all are live.
  Fail closed -- /v1/link is invite-only, so hitting the cap is an attack.
- The clock was read outside the lock, so racing threads could append out of
  order; both retry_after (hits[0]) and reclamation (v[-1]) assume the list
  is chronological. Moved inside.
- Config types were unvalidated: `"enabled": null` or `0` silently disabled
  limiting, and the string "false" enabled XFF trust (non-empty strings are
  truthy). Booleans must now be real JSON booleans; rate_limit must be an
  object.

Codex confirmed no path-variant bypass (dispatch is exact-match) and no
keep-alive/pipelining bypass (rejects set close_connection).

Tests 87 -> 99: capacity fail-closed with the victim's window proven
untouched through a 40-key flood, 200-thread chronological-order check,
untrusted-peer spoof, junk XFF, and every config-type trap.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
This commit is contained in:
Nirav Patel
2026-08-22 23:22:44 -04:00
co-authored by Claude Opus 5
parent e2fed5886f
commit c442721aff
3 changed files with 236 additions and 47 deletions
+23 -11
View File
@@ -102,25 +102,37 @@ header, decided *before* the body is read so an abusive caller costs nothing.
"rate_limit": {
"enabled": true,
"trust_forwarded_for": false,
"trusted_proxies": [],
"rules": {"/v1/link": [5, 3600], "/v1/repos": [60, 3600]}
}
```
* A malformed rule **refuses startup** rather than silently meaning
unlimited; `[0, N]` disables an endpoint outright.
* Anything malformed **refuses startup** rather than silently meaning
unlimited: a bad rule, a non-boolean `enabled`/`trust_forwarded_for` (JSON
`null`, `0`, or the *string* `"false"` — every non-empty string is truthy),
or a `rate_limit` that is not an object. `[0, N]` disables an endpoint
outright.
* Denied requests are *not* recorded, so a client that keeps hammering cannot
push its own window forward and lock itself out permanently.
* State is an in-process dict behind a lock — granthi-link is one
`ThreadingHTTPServer`, so that is the entire store. **If this ever runs
multi-process or multi-host, the limiter must move with it.** The key store
is capped (`MAX_RATE_KEYS`); at capacity it drops least-recent windows and
logs loudly, which is fail-open, chosen over an unbounded dict that is a
memory DoS.
* `trust_forwarded_for` is **off** by default. Turn it on only behind
cloudflared, where every request otherwise arrives from the tunnel and one
abuser would starve everyone. A caller can *prepend* anything to
`X-Forwarded-For`; a trusted proxy *appends* the peer it actually saw, so
the service reads the **last** entry, never the first.
multi-process or multi-host, the limiter must move with it.** The clock is
read *inside* the lock; taken outside, racing threads append out of order
and both `Retry-After` and window reclamation silently go wrong.
* The key store is capped (`MAX_RATE_KEYS`). At capacity it reclaims expired
windows, and if every window is still live it **refuses the new key**
fail closed. Evicting a live window would let an attacker who can mint many
distinct keys clear their *own* limit on demand, which is worse than
turning a request away on an invite-only endpoint.
* `trust_forwarded_for` is **off** by default, and turning it on **requires a
non-empty `trusted_proxies`** — the header is honored only when the socket
peer is in that list. Without it, anyone reaching the origin directly (it
also listens on the tailnet) could pick and rotate their own rate-limit key
just by sending a header. Turn it on when exposing behind cloudflared,
where every request otherwise arrives from the tunnel and one abuser would
starve everyone. A caller can *prepend* anything to `X-Forwarded-For`; a
trusted proxy *appends* the peer it actually saw, so the service reads the
**last** entry, never the first, and requires it to parse as a real IP.
#### Identity binding (`state.json`)