Skip to content

CL-6521: Sign-in rate limiter no longer locks victims out indefinitely - #263

Merged
TheGreatAxios merged 3 commits into
mainfrom
cl-signin-lockout-fix
Aug 21, 2026
Merged

CL-6521: Sign-in rate limiter no longer locks victims out indefinitely#263
TheGreatAxios merged 3 commits into
mainfrom
cl-signin-lockout-fix

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

Security fix — live, remotely exploitable DoS. The account-keyed sign-in
rate limiter (apps/hub/src/sign-in-rate-limit.ts, mounted in
apps/hub/src/index.ts) consumed budget on every sign-in attempt,
including successful ones, since it ran ahead of auth.handler and never
inspected the outcome — and it never reset on success. An unauthenticated
caller could POST /api/auth/sign-in/email with {"email":"victim@corp.com"}
and a garbage password 10x/60s (~0.17 rps) and keep that account's bucket
pinned at count >= max forever, so the real owner presenting their
correct 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-for is forgeable from same-project Railway private
networking and there's no stable edge CIDR to build trustedProxies from)
is sound and unchanged. What was wrong is what consumed budget.

Now: the auth handler always lets a sign-in attempt reach auth.handler and
inspects the real response status before ever touching the limiter.

  • Success (2xx) → recordSuccess(email) clears the account's bucket
    outright, and the response is returned as-is.
  • Failure (anything else) → recordFailure(email) consumes budget; once
    the account has already exhausted its budget for the window, the response
    is replaced with a 429 instead of the underlying auth failure.

What this means for the two cases that matter:

  • The genuine account owner: no matter how many wrong guesses an attacker
    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 the
    bucket for good measure.
  • A genuine distributed password spray against one account (many source
    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 max wrong guesses land
    inside windowSeconds, every further failure in that window gets a
    generic 429 instead of a distinguishing auth failure. The attacker
    cannot get more than max distinguishable guesses per window against the
    account, no matter how many IPs they spread the attempts across.

Two adjacent defects fixed in the same path

  1. Trust-boundary violation. index.ts parsed the sign-in body with
    body as { email: unknown } / as { email: string } inside a bare
    catch { email = ""; } — an untrusted request body handled with unsafe
    casts, and all malformed traffic shared one global "" bucket. Now
    parsed with an arktype schema (type({ email: "string" })); a body that
    doesn't parse to that shape never touches the limiter at all — there's no
    account to key a bucket on, and auth.handler rejects it on its own
    terms.
  2. Wrong doc. config.ts documented SIGNIN_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-After instead of
the standard Retry-After (RFC 9110); composition.test.ts's assertion on
the header name is corrected to match.

Tests

  • apps/hub/src/sign-in-rate-limit.test.ts (unit): renamed consume call
    sites to recordFailure, and added coverage that recordSuccess clears
    a fully-exhausted bucket so the next failure is treated as fresh.
  • apps/hub/test/composition.test.ts (DB-gated, extended, not duplicated):
    • an attacker exhausting an account's failure budget with wrong guesses
      never blocks the owner's correct password, and the success resets the
      budget for the next failure;
    • malformed sign-in bodies (no email field) are never rate-limited
      together and never block a real account's first attempt.
    • the three existing tests (forged/rotating IP can't outrun the budget,
      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 --write run on all changed files (no diff).
  • Per dev-machine constraints, no repo-root bun run check was run —
    verification was package-scoped (apps/hub) only.

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.
@TheGreatAxios
TheGreatAxios merged commit 58afc6f into main Aug 21, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant