Rate-limit /api/auth/unlock with one global counter #160
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus!160
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/unlock-rate-limit"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #149. Implements item 1 of #110.
/api/auth/unlockhad no attempt counter, no backoff and no failure logging, on an instance that is internet-facing today. Argon2id bounds the cost per guess, not the number of guesses — and the server pays that cost, so unbounded attempts were a guessing channel and a CPU-exhaustion vector at once.Global only — per-IP was not shippable, and the reason is worse than "unverified"
#149's comment said to ship the global limiter alone if the right
XFF_DEPTHcould not be established with confidence. It cannot, and while checking I found a harder blocker than the one the issue describes.Verified by reading
@sveltejs/adapter-node@5.5.4(files/handler.js:1274-1381):ADDRESS_HEADERis read once at module scope. It is process-global — there is no per-route form.getClientAddress()throws whenADDRESS_HEADERnames a header the request lacks.So turning it on for
/unlockalso redirectsgetClientAddress()for the four/api/agent/*callbacks, which reach host-mode Nexus directly over the workers bridge carrying no such header. Those callbacks would start throwing, taking the bridge-IP guard (fact #17) with them. Per-IP on this deployment is not "unverified", it is mutually exclusive with the worker callbacks.Independently:
X-Forwarded-Foris client-appendable, and DEPLOYMENT.md's own nginx snippet does not set it at all — it setsX-Real-IPonly. Under the documented config the header is whatever the caller sent, so a left-most read lets anyone mint a fresh bucket per request while the limiter looks like it works.There is therefore no per-IP key in this PR, and a test forges a different
X-Forwarded-Foron every request so a naive one added later goes red instead of shipping.One consequence worth stating plainly: a global counter means a determined attacker can keep the operator out for as long as they keep guessing. That is the trade, it is bounded (below), and DEPLOYMENT.md says so rather than implying a safety the design does not have.
What ships
UnlockLimiter(nexus/src/lib/server/auth/unlock-limiter.ts), one instance onsingletons:Four properties the issue asked for, and how each is met structurally rather than by comment:
ATTEMPT_DECAY_MSis strictly longer thanMAX_DELAY_MSfor the mirror-image reason.Operator lockout recovery — the design decision
Never permanent, and there is no recovery secret to lose:
docker compose restart nexusclears it at once.(2) is deliberate, not incidental: an operator locked out of a single-operator instance has shell access to the host by definition, and there is no second channel. Documented in DEPLOYMENT.md's new "Unlock rate limiting" section alongside the trade above.
Gates
pnpm typecheckpnpm testpnpm lintThe two new test files were also run 8 consecutive times, with and without thread constraints, to confirm they are deterministic.
Guards proven able to fail
Every one of the 19 tests was mutation-tested: the thing it protects was broken and the test confirmed red, then restored. 20 mutations, each killing its guard:
past = attempts)succeeded()does not clear the counterX-Forwarded-ForRetry-Aftersent in ms, not secondshumanDelaydrops its minutes branchwarnagainTwo mutations did not behave as expected, and both changed the code rather than the test:
succeeded()survived its first mutation. It zeroedlastAttemptAtas well asattempts, and the decay check then reset the count a second way — so a mutation to either line passed all 13 tests. The redundant line is gone; the deadline is now derived from the count rather than stored, which removes the second field entirely.nowis before the last attempt — a clock stepping backwards (NTP correction, restored snapshot) — including right after a successful unlock had cleared the count, i.e. a lockout with no counter behind it. The refusal is now gated on a delay actually being armed, with a named test for it.An earlier mutation was also discarded as vacuous: setting
FREE_ATTEMPTS = 0left the free-window test comparing[]to[]. It is a policy constant, not a mechanism, so the mechanism was mutated instead.Review
/simplify(4 agents) andpr-review-toolkit:review-pr(5 agents) both ran. Acted on:warn— the one path whose rate an attacker fully controls, at a level production keeps enabled. A disk-fill vector that also buried the signal. Nowdebug; the failed-guesswarnis the attack signature and the limiter throttles it by construction.Retry-Aftervalue,humanDelay's minutes branch, and the logging the issue explicitly requires.delayFor()while a test comment went on naming it./api/auth/setupderives a key. Both statements now say "once setup is complete".readJsonBodyfrom$server/lib/httpinstead of the route's local copy; cutunlock-limiter.tsfrom 54% comments to a pointer at fact #29.Rejected, with reasons:
UnlockAttempt— the reviewer scoped it itself to "if and when a second consumer appears". One producer, one consumer today.ATTEMPT_DECAY_MS > MAX_DELAY_MS, and the settings registry has no cross-field validation, so exposing them would let an operator silently reconfigure the vulnerability back in.succeeded()after the session is seated — if seating throws, the operator has still proved the passphrase; leaving the counter armed would let correct guesses escalate their own lockout, which is the one outcome this file exists to prevent.getClientAddress()throws — the same misconfiguration already 500s every worker callback loudly, since those call it unwrapped._handleUnlockexport for DI-style testing — a test-only seam; driving the real exportedPOSTis what makes the forged-header guard meaningful.Deliberately not done
/api/auth/setupis unguarded before first-run completes, where it does reach the KDF with no lock aroundisSetupComplete. Out of scope for #149, and the marginal risk is low: an instance that has not been set up holds no secret and can be claimed outright by anyone who can reach it, so CPU exhaustion is not what is at stake in that window. Worth its own issue.