CL-6521: Sign-in rate limiter no longer locks victims out indefinitely - #263
Merged
Conversation
Cover the fix for CL-6521: a correct password must always succeed regardless of prior failed attempts against the same account, a successful sign-in must clear the account's budget, repeated failures must still be bounded, and malformed sign-in bodies must never share one rate-limit bucket.
The account-keyed limiter consumed budget on every sign-in attempt, including successful ones, and never reset — a caller could POST a known email with garbage passwords a few times a minute and keep the real owner permanently locked out with correct credentials, since the budget never released. The limiter's own comment claimed this self-heals; it doesn't against a repeating attacker. The auth handler now always lets a sign-in attempt reach auth.handler and inspects the real response before touching the limiter: success clears the account's bucket, failure consumes it. A correct password is never rejected on account of someone else's prior wrong guesses. A distributed spray against one account is still bounded, since the key is the email, not the source. Also: the sign-in body is now parsed with an arktype schema instead of unsafe `as` casts, and a body that doesn't parse to an email never touches the limiter at all, instead of sharing one bucket with every other malformed request. The 429 response now sends the standard Retry-After header instead of X-Retry-After.
config.ts documented these as a per-IP sign-in limit; the limiter has always been account-keyed and deliberately ignores client IP (see sign-in-rate-limit.ts). Left uncorrected, an operator would tune it against the wrong threat model.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Security fix — live, remotely exploitable DoS. The account-keyed sign-in
rate limiter (
apps/hub/src/sign-in-rate-limit.ts, mounted inapps/hub/src/index.ts) consumed budget on every sign-in attempt,including successful ones, since it ran ahead of
auth.handlerand neverinspected the outcome — and it never reset on success. An unauthenticated
caller could
POST /api/auth/sign-in/emailwith{"email":"victim@corp.com"}and a garbage password 10x/60s (~0.17 rps) and keep that account's bucket
pinned at
count >= maxforever, so the real owner presenting theircorrect password got 429 indefinitely. The file's own comment claimed
a forced lockout "self-heals within the window and never compounds across
windows" — true only against a one-shot attacker, not a repeating one.
Filed as CL-6521.
The fix
The account-keyed design from CL-6494 (key on the target email, not client
IP, because
x-forwarded-foris forgeable from same-project Railway privatenetworking and there's no stable edge CIDR to build
trustedProxiesfrom)is sound and unchanged. What was wrong is what consumed budget.
Now: the auth handler always lets a sign-in attempt reach
auth.handlerandinspects the real response status before ever touching the limiter.
2xx) →recordSuccess(email)clears the account's bucketoutright, and the response is returned as-is.
recordFailure(email)consumes budget; oncethe account has already exhausted its budget for the window, the response
is replaced with a
429instead of the underlying auth failure.What this means for the two cases that matter:
has thrown at their account, their own correct-password attempt is never
pre-emptively blocked — it's never gated on the stored count at all,
only on whether this attempt succeeds. A correct password always
reaches
auth.handler, always succeeds, and its success resets thebucket for good measure.
IPs, one target email — precisely the case account-keying exists to
stop): still bounded. Every wrong guess against that email counts against
the same bucket regardless of source IP, so once
maxwrong guesses landinside
windowSeconds, every further failure in that window gets ageneric
429instead of a distinguishing auth failure. The attackercannot get more than
maxdistinguishable guesses per window against theaccount, no matter how many IPs they spread the attempts across.
Two adjacent defects fixed in the same path
index.tsparsed the sign-in body withbody as { email: unknown }/as { email: string }inside a barecatch { email = ""; }— an untrusted request body handled with unsafecasts, and all malformed traffic shared one global
""bucket. Nowparsed with an arktype schema (
type({ email: "string" })); a body thatdoesn't parse to that shape never touches the limiter at all — there's no
account to key a bucket on, and
auth.handlerrejects it on its ownterms.
config.tsdocumentedSIGNIN_RATE_LIMIT_*as "per-IP"/ "the maximum sign-in attempts a single IP may make" — the limiter is
deliberately per-account and ignores IP. Corrected so an operator tunes it
against the right threat model.
Minor, fixed while here: the 429 response sent
X-Retry-Afterinstead ofthe standard
Retry-After(RFC 9110);composition.test.ts's assertion onthe header name is corrected to match.
Tests
apps/hub/src/sign-in-rate-limit.test.ts(unit): renamedconsumecallsites to
recordFailure, and added coverage thatrecordSuccessclearsa fully-exhausted bucket so the next failure is treated as fresh.
apps/hub/test/composition.test.ts(DB-gated, extended, not duplicated):never blocks the owner's correct password, and the success resets the
budget for the next failure;
emailfield) are never rate-limitedtogether and never block a real account's first attempt.
two accounts get independent budgets, retry-hint present) are
unchanged in intent and still pass.
Verification
apps/hub:bun run typecheck— clean.apps/hub:bun test src/sign-in-rate-limit.test.ts test/composition.test.ts— 7 pass, 11 skip (DB-gated composition tests skip without a local
DATABASE_URL; they run against a real Postgres in CI).bunx prettier --writerun on all changed files (no diff).bun run checkwas run —verification was package-scoped (
apps/hub) only.