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
+23 -3
View File
@@ -31,6 +31,18 @@ const LoginSchema = z.object({
totp: z.string().max(16).optional(),
});
function extractClientIp(request: Request | undefined): string | null {
if (!request) return null;
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = request.headers.get("x-real-ip");
if (realIp) return realIp.trim();
return null;
}
const config = {
...authConfig,
trustHost: true,
@@ -42,17 +54,25 @@ const config = {
password: { label: "Password", type: "password" },
totp: { label: "TOTP", type: "text" },
},
async authorize(credentials) {
async authorize(credentials, request) {
const parsed = LoginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password, totp } = parsed.data;
const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
// Rate limit: 5 login attempts per 15 minutes per email
// Rate limit: 5 attempts per 15 min, keyed on BOTH email and
// source IP. Keying on email alone permits per-email lockout DoS
// and lets a single IP brute-force unlimited emails; keying on
// IP alone lets a botnet bypass the limit. Both buckets must be
// within budget for the attempt to proceed (CWE-307).
const ip = extractClientIp(request);
const rateLimitKeys = ip
? [`email:${email.toLowerCase()}`, `ip:${ip}`]
: [`email:${email.toLowerCase()}`];
const rateLimitResult = isE2eTestMode
? { allowed: true }
: await authRateLimiter(email.toLowerCase());
: await authRateLimiter(rateLimitKeys);
if (!rateLimitResult.allowed) {
// Audit failed login (rate limited)
void createAuditEntry({