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:
co-authored by
Claude Opus 5
parent
e2fed5886f
commit
c442721aff
@@ -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`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user