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:
@@ -90,15 +90,16 @@ function makeSelfServiceCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function makePublicCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
function makePublicCtx(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
db: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
...((dbOverrides.user as object | undefined) ?? {}),
|
||||
...((overrides.user as object | undefined) ?? {}),
|
||||
},
|
||||
},
|
||||
clientIp: (overrides.clientIp as string | null | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,14 +278,27 @@ describe("verifyTotp", () => {
|
||||
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the rate limiter with the userId as key", async () => {
|
||||
it("calls the rate limiter with both userId and client IP as keys", async () => {
|
||||
totpValidateMock.mockReturnValue(0);
|
||||
const ctx = makePublicCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue(mfaUser) },
|
||||
clientIp: "198.51.100.7",
|
||||
});
|
||||
await verifyTotp(ctx as Parameters<typeof verifyTotp>[0], {
|
||||
userId: "user_1",
|
||||
token: "123456",
|
||||
});
|
||||
expect(totpRateLimiterMock).toHaveBeenCalledWith(["user:user_1", "ip:198.51.100.7"]);
|
||||
});
|
||||
|
||||
it("falls back to userId-only keying when no client IP is available", async () => {
|
||||
totpValidateMock.mockReturnValue(0);
|
||||
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
|
||||
await verifyTotp(ctx as Parameters<typeof verifyTotp>[0], {
|
||||
userId: "user_1",
|
||||
token: "123456",
|
||||
});
|
||||
expect(totpRateLimiterMock).toHaveBeenCalledWith("user_1");
|
||||
expect(totpRateLimiterMock).toHaveBeenCalledWith(["user:user_1"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user