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
+21 -5
View File
@@ -5,6 +5,17 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import type { NextRequest } from "next/server";
import { auth } from "~/server/auth.js";
function extractClientIp(req: NextRequest): string | null {
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = req.headers.get("x-real-ip");
if (realIp) return realIp.trim();
return null;
}
// Throttle lastActiveAt updates: max once per 60s per user
const lastActiveCache = new Map<string, number>();
const ACTIVITY_THROTTLE_MS = 60_000;
@@ -14,10 +25,14 @@ function trackActivity(userId: string) {
const last = lastActiveCache.get(userId) ?? 0;
if (now - last < ACTIVITY_THROTTLE_MS) return;
lastActiveCache.set(userId, now);
prisma.user.update({
where: { id: userId },
data: { lastActiveAt: new Date(now) },
}).catch(() => {/* ignore */});
prisma.user
.update({
where: { id: userId },
data: { lastActiveAt: new Date(now) },
})
.catch(() => {
/* ignore */
});
}
const handler = async (req: NextRequest) => {
@@ -63,7 +78,8 @@ const handler = async (req: NextRequest) => {
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createTRPCContext({ session, dbUser, roleDefaults }),
createContext: () =>
createTRPCContext({ session, dbUser, roleDefaults, clientIp: extractClientIp(req) }),
};
if (process.env["NODE_ENV"] === "development") {