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
@@ -1,8 +1,5 @@
import { Prisma } from "@capakraken/db";
import {
dashboardLayoutSchema,
normalizeDashboardLayout,
} from "@capakraken/shared/schemas";
import { dashboardLayoutSchema, normalizeDashboardLayout } from "@capakraken/shared/schemas";
import type { ColumnPreferences } from "@capakraken/shared/types";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -20,9 +17,20 @@ export const ToggleFavoriteProjectInputSchema = z.object({
});
export const SetColumnPreferencesInputSchema = z.object({
view: z.enum(["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"]),
view: z.enum([
"resources",
"projects",
"allocations",
"vacations",
"roles",
"users",
"blueprints",
]),
visible: z.array(z.string()).optional(),
sort: z.object({ field: z.string(), dir: z.enum(["asc", "desc"]) }).nullable().optional(),
sort: z
.object({ field: z.string(), dir: z.enum(["asc", "desc"]) })
.nullable()
.optional(),
rowOrder: z.array(z.string()).nullable().optional(),
});
@@ -36,7 +44,7 @@ export const VerifyTotpInputSchema = z.object({
});
type UserSelfServiceContext = Pick<TRPCContext, "db" | "dbUser" | "session">;
type UserPublicContext = Pick<TRPCContext, "db">;
type UserPublicContext = Pick<TRPCContext, "db" | "clientIp">;
export async function getCurrentUserProfile(ctx: UserSelfServiceContext) {
return findUniqueOrThrow(
@@ -61,9 +69,7 @@ export async function getDashboardLayout(ctx: UserSelfServiceContext) {
select: { dashboardLayout: true, updatedAt: true },
});
const normalized = user?.dashboardLayout
? normalizeDashboardLayout(user.dashboardLayout)
: null;
const normalized = user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null;
return {
layout: normalized?.widgets.length ? normalized : null,
updatedAt: user?.updatedAt ?? null,
@@ -131,7 +137,9 @@ export async function setColumnPreferences(
select: { columnPreferences: true },
});
const prefs = (existing?.columnPreferences ?? {}) as ColumnPreferences;
const prev = (prefs[input.view] as import("@capakraken/shared").ViewPreferences | undefined) ?? { visible: [] };
const prev = (prefs[input.view] as import("@capakraken/shared").ViewPreferences | undefined) ?? {
visible: [],
};
const merged: import("@capakraken/shared").ViewPreferences = {
visible: input.visible ?? prev.visible,
@@ -183,13 +191,30 @@ export async function verifyAndEnableTotp(
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
}) as Promise<{ id: string; name: string | null; email: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null>,
select: {
id: true,
name: true,
email: true,
totpSecret: true,
totpEnabled: true,
lastTotpAt: true,
},
}) as Promise<{
id: string;
name: string | null;
email: string;
totpSecret: string | null;
totpEnabled: boolean;
lastTotpAt: Date | null;
} | null>,
"User",
);
if (!user.totpSecret) {
throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." });
throw new TRPCError({
code: "BAD_REQUEST",
message: "No TOTP secret generated. Call generateTotpSecret first.",
});
}
if (user.totpEnabled) {
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is already enabled." });
@@ -211,11 +236,11 @@ export async function verifyAndEnableTotp(
}
// Replay-attack prevention: reject if the same 30-second window was already used
if (
user.lastTotpAt != null &&
Date.now() - user.lastTotpAt.getTime() < 30_000
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP code already used. Wait for the next code." });
if (user.lastTotpAt != null && Date.now() - user.lastTotpAt.getTime() < 30_000) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "TOTP code already used. Wait for the next code.",
});
}
await (ctx.db.user.update as Function)({
@@ -241,16 +266,28 @@ export async function verifyTotp(
ctx: UserPublicContext,
input: z.infer<typeof VerifyTotpInputSchema>,
) {
// Rate limit: max 10 attempts per 30 seconds per userId to prevent brute-force (A01-1)
const rl = await totpRateLimiter(input.userId);
// Rate limit keyed on BOTH userId and source IP. userId-only keying
// permits targeted user-lockout DoS; IP-only permits botnet bypass.
// Both buckets must allow for the attempt to proceed (CWE-307, A01-1).
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
const totpKeys = ipKey ? [`user:${input.userId}`, ipKey] : [`user:${input.userId}`];
const rl = await totpRateLimiter(totpKeys);
if (!rl.allowed) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many TOTP attempts. Please wait before trying again." });
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many TOTP attempts. Please wait before trying again.",
});
}
const user = await ctx.db.user.findUnique({
const user = (await ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
}) as { id: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null;
})) as {
id: string;
totpSecret: string | null;
totpEnabled: boolean;
lastTotpAt: Date | null;
} | null;
// Generic error for both not-found and TOTP-not-enabled to prevent user enumeration
if (!user || !user.totpEnabled || !user.totpSecret) {
@@ -273,10 +310,7 @@ export async function verifyTotp(
}
// Replay-attack prevention: reject if the same 30-second window was already used
if (
user.lastTotpAt != null &&
Date.now() - user.lastTotpAt.getTime() < 30_000
) {
if (user.lastTotpAt != null && Date.now() - user.lastTotpAt.getTime() < 30_000) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
}