fix(link): 3 more codex [P2] findings — per-route caps, bool rules, proxy validation
Round 2 of review on the same branch: - The fail-closed capacity guard was itself a DoS lever. MAX_RATE_KEYS was global, and the limiter runs before auth, so a flood of cheap /v1/repos keys could exhaust the table and 429 never-seen /v1/link clients until live windows expired. Budgets are now per route. - Rule values accepted booleans: bool subclasses int, so isinstance let [5, true] through as a 1-SECOND window (5/hour -> ~5/sec) and false in the limit slot disabled the endpoint. Now `type(x) is int`. - trusted_proxies was unvalidated: a bare string would be iterated character by character, malformed entries only surfaced as a per-request log line, and 0.0.0.0/0 or ::/0 restored "trust XFF from any peer" — the exact hole the setting closes. Now parsed and validated once at startup, wildcards refused, and _ip_in_any takes pre-parsed networks so nothing can degrade to a silent per-request skip. Tests 99 -> 108: cross-route flood isolation, per-route reclamation windows, every bool-in-rule position, bare-string and wildcard proxies, and a v4/v6 mismatch case. 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
c442721aff
commit
c742ca4798
@@ -119,11 +119,16 @@ header, decided *before* the body is read so an abusive caller costs nothing.
|
||||
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.
|
||||
* The key store is capped (`MAX_RATE_KEYS`) **per route**, not globally. 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.
|
||||
The per-route budget matters just as much: with one shared table, a flood
|
||||
of cheap `/v1/repos` keys would exhaust it and lock brand-new `/v1/link`
|
||||
clients out, turning the fail-closed guard into a cross-route DoS.
|
||||
* Rule values must be real integers. `bool` subclasses `int` in Python, so
|
||||
`[5, true]` would otherwise pass as a **1-second** window — an hourly limit
|
||||
quietly becoming ~5/sec.
|
||||
* `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
|
||||
@@ -133,6 +138,10 @@ header, decided *before* the body is read so an abusive caller costs nothing.
|
||||
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.
|
||||
`trusted_proxies` is validated at startup: it must be a list (a bare string
|
||||
would be iterated character by character), every entry a valid network, and
|
||||
wildcards (`0.0.0.0/0`, `::/0`) are refused outright — they would restore
|
||||
exactly the "trust anyone" hole the setting exists to close.
|
||||
|
||||
#### Identity binding (`state.json`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user