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