security: rate-limit IP-keyed, fail-closed on empty key (#37)

Rate-limiter now accepts string | string[] so callers can key on
multiple buckets simultaneously. If any bucket is exhausted the
request is denied, which lets login/TOTP/reset-password throttle on
BOTH user identifier and source IP without either becoming a bypass.

Fail-closed: empty/whitespace-only keys now deny by default instead
of silently allowing unbounded attempts (was CWE-307 gap).

Degraded-fallback divisor reduced from /10 to /2 — the old aggressive
clamp forced-logged-out legitimate users during brief Redis outages;
/2 still meaningfully slows distributed brute-force.

Callers updated:
- auth.ts (login): both email: and ip: buckets
- auth router requestPasswordReset: email + IP
- auth router resetPassword: IP before lookup, email-reset after
- invite router getInvite/acceptInvite: IP
- user-self-service verifyTotp: userId + IP

TRPCContext now carries clientIp; web tRPC route extracts it from
X-Forwarded-For / X-Real-IP.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 08:19:33 +02:00
parent 534945f6e3
commit 3c5d1d37f7
10 changed files with 290 additions and 106 deletions
+29 -7
View File
@@ -27,7 +27,11 @@ export const authRouter = createTRPCRouter({
requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ ctx, input }) => {
const rl = await authRateLimiter(input.email);
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
const keys = ipKey
? [`email:${input.email.toLowerCase()}`, ipKey]
: [`email:${input.email.toLowerCase()}`];
const rl = await authRateLimiter(keys);
if (!rl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
@@ -78,12 +82,19 @@ export const authRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const rl = await authRateLimiter(input.token);
if (!rl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many password reset attempts. Please wait before trying again.",
});
// Rate-limit keyed on IP (token is always new so token-keying is a no-op).
// We cannot key on the resolved email before the token lookup; fall back
// to IP-only here and apply an email-keyed limit AFTER the successful
// lookup to bound per-email brute-force.
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
if (ipKey) {
const rl = await authRateLimiter(ipKey);
if (!rl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many password reset attempts. Please wait before trying again.",
});
}
}
const record = await ctx.db.passwordResetToken.findUnique({
@@ -103,6 +114,17 @@ export const authRouter = createTRPCRouter({
throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." });
}
// Second-layer limit keyed on the resolved email, so a targeted
// attacker cannot exhaust reset attempts for a known user even if
// they cycle source IPs.
const emailRl = await authRateLimiter(`email-reset:${record.email.toLowerCase()}`);
if (!emailRl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many password reset attempts. Please wait before trying again.",
});
}
const { hash } = await import("@node-rs/argon2");
const passwordHash = await hash(input.password);