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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user