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:
@@ -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." });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user