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({
|
||||
|
||||
@@ -103,9 +103,9 @@ describe("rate limiter", () => {
|
||||
}));
|
||||
|
||||
const { createRateLimiter } = await import("../middleware/rate-limit.js");
|
||||
// Degraded fallback uses max(1, floor(maxRequests/10)), so with
|
||||
// maxRequests=20 the degraded limit is 2.
|
||||
const limiter = createRateLimiter(60_000, 20, {
|
||||
// Degraded fallback uses max(1, floor(maxRequests/2)), so with
|
||||
// maxRequests=4 the degraded limit is 2 attempts within the window.
|
||||
const limiter = createRateLimiter(60_000, 4, {
|
||||
backend: "redis",
|
||||
redisUrl: "redis://test",
|
||||
name: "redis-fallback-test",
|
||||
@@ -120,4 +120,39 @@ describe("rate limiter", () => {
|
||||
expect(third.allowed).toBe(false);
|
||||
expect(third.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("denies by default when called with an empty key (fail-closed)", async () => {
|
||||
const { createRateLimiter } = await import("../middleware/rate-limit.js");
|
||||
const limiter = createRateLimiter(60_000, 5, { backend: "memory", name: "empty-key-test" });
|
||||
|
||||
const empty = await limiter("");
|
||||
const whitespace = await limiter(" ");
|
||||
const emptyArray = await limiter([]);
|
||||
const allEmpty = await limiter(["", " "]);
|
||||
|
||||
expect(empty.allowed).toBe(false);
|
||||
expect(whitespace.allowed).toBe(false);
|
||||
expect(emptyArray.allowed).toBe(false);
|
||||
expect(allEmpty.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("denies if any key in a multi-key call is over its limit", async () => {
|
||||
const { createRateLimiter } = await import("../middleware/rate-limit.js");
|
||||
const limiter = createRateLimiter(60_000, 2, { backend: "memory", name: "multi-key-test" });
|
||||
|
||||
// Exhaust the "email:a" bucket alone
|
||||
await limiter("email:a");
|
||||
await limiter("email:a");
|
||||
const emailExhausted = await limiter("email:a");
|
||||
expect(emailExhausted.allowed).toBe(false);
|
||||
|
||||
// A call keyed on both email:a AND ip:x must deny because email:a is
|
||||
// exhausted, even though ip:x is fresh.
|
||||
const combined = await limiter(["email:a", "ip:x"]);
|
||||
expect(combined.allowed).toBe(false);
|
||||
|
||||
// A fresh bucket pair still succeeds.
|
||||
const freshPair = await limiter(["email:b", "ip:y"]);
|
||||
expect(freshPair.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,15 +90,16 @@ function makeSelfServiceCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function makePublicCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
function makePublicCtx(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
db: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
...((dbOverrides.user as object | undefined) ?? {}),
|
||||
...((overrides.user as object | undefined) ?? {}),
|
||||
},
|
||||
},
|
||||
clientIp: (overrides.clientIp as string | null | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,14 +278,27 @@ describe("verifyTotp", () => {
|
||||
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the rate limiter with the userId as key", async () => {
|
||||
it("calls the rate limiter with both userId and client IP as keys", async () => {
|
||||
totpValidateMock.mockReturnValue(0);
|
||||
const ctx = makePublicCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue(mfaUser) },
|
||||
clientIp: "198.51.100.7",
|
||||
});
|
||||
await verifyTotp(ctx as Parameters<typeof verifyTotp>[0], {
|
||||
userId: "user_1",
|
||||
token: "123456",
|
||||
});
|
||||
expect(totpRateLimiterMock).toHaveBeenCalledWith(["user:user_1", "ip:198.51.100.7"]);
|
||||
});
|
||||
|
||||
it("falls back to userId-only keying when no client IP is available", async () => {
|
||||
totpValidateMock.mockReturnValue(0);
|
||||
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
|
||||
await verifyTotp(ctx as Parameters<typeof verifyTotp>[0], {
|
||||
userId: "user_1",
|
||||
token: "123456",
|
||||
});
|
||||
expect(totpRateLimiterMock).toHaveBeenCalledWith("user_1");
|
||||
expect(totpRateLimiterMock).toHaveBeenCalledWith(["user:user_1"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ type CreateRateLimiterOptions = {
|
||||
};
|
||||
|
||||
export interface RateLimiter {
|
||||
(key: string): Promise<RateLimitResult>;
|
||||
(key: string | readonly string[]): Promise<RateLimitResult>;
|
||||
reset(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -212,27 +212,19 @@ export function createRateLimiter(
|
||||
|
||||
// When Redis is unavailable, apply a stricter limit to compensate for
|
||||
// per-node isolation (each process keeps independent in-memory counters,
|
||||
// so the effective cluster-wide limit is maxRequests × nodeCount).
|
||||
// so the effective cluster-wide limit is maxRequests × nodeCount). A
|
||||
// /2 divisor keeps legitimate users out of forced-logout while still
|
||||
// meaningfully slowing distributed brute-force during Redis outages.
|
||||
const degradedMemoryBackend = createMemoryBackend(
|
||||
windowMs,
|
||||
Math.max(1, Math.floor(maxRequests / 10)),
|
||||
Math.max(1, Math.floor(maxRequests / 2)),
|
||||
);
|
||||
let redisDegraded = false;
|
||||
|
||||
const check = (async (key: string) => {
|
||||
const normalizedKey = key.trim().toLowerCase();
|
||||
if (!normalizedKey) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: maxRequests,
|
||||
resetAt: new Date(Date.now() + windowMs),
|
||||
};
|
||||
}
|
||||
|
||||
async function checkOne(normalizedKey: string): Promise<RateLimitResult> {
|
||||
if (!redisBackend) {
|
||||
return memoryBackend.check(normalizedKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await redisBackend.check(normalizedKey);
|
||||
if (redisDegraded) {
|
||||
@@ -244,6 +236,44 @@ export function createRateLimiter(
|
||||
redisDegraded = true;
|
||||
return degradedMemoryBackend.check(normalizedKey);
|
||||
}
|
||||
}
|
||||
|
||||
const check = (async (key: string | readonly string[]) => {
|
||||
const rawKeys = Array.isArray(key) ? key : [key as string];
|
||||
const normalizedKeys = rawKeys
|
||||
.map((k) => (typeof k === "string" ? k.trim().toLowerCase() : ""))
|
||||
.filter((k) => k.length > 0);
|
||||
|
||||
// Fail-closed: if every supplied key is empty or whitespace the caller
|
||||
// has no identity to throttle; deny rather than letting unbounded
|
||||
// attempts through (CWE-307).
|
||||
if (normalizedKeys.length === 0) {
|
||||
logger.warn({ limiter: name }, "Rate limiter called with empty key — denying by default");
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date(Date.now() + windowMs),
|
||||
};
|
||||
}
|
||||
|
||||
// Check every bucket. If any bucket is exhausted, the request is
|
||||
// denied; this allows callers to key on both user identifier AND
|
||||
// request IP without either becoming a bypass.
|
||||
let denied: RateLimitResult | null = null;
|
||||
let earliestReset = new Date(Date.now() + windowMs);
|
||||
let minRemaining = Number.POSITIVE_INFINITY;
|
||||
for (const normalizedKey of normalizedKeys) {
|
||||
const result = await checkOne(normalizedKey);
|
||||
if (!result.allowed && !denied) denied = result;
|
||||
if (result.resetAt < earliestReset) earliestReset = result.resetAt;
|
||||
if (result.remaining < minRemaining) minRemaining = result.remaining;
|
||||
}
|
||||
if (denied) return denied;
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: minRemaining === Number.POSITIVE_INFINITY ? maxRequests : minRemaining,
|
||||
resetAt: earliestReset,
|
||||
};
|
||||
}) as RateLimiter;
|
||||
|
||||
check.reset = async () => {
|
||||
|
||||
@@ -1677,6 +1677,7 @@ export function createScopedCallerContext(ctx: ToolContext): TRPCContext {
|
||||
db: ctx.db,
|
||||
dbUser: ctx.dbUser,
|
||||
roleDefaults: ctx.roleDefaults ?? null,
|
||||
clientIp: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,11 @@ export const authRouter = createTRPCRouter({
|
||||
requestPasswordReset: publicProcedure
|
||||
.input(z.object({ email: z.string().email() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rl = await authRateLimiter(input.email);
|
||||
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
|
||||
const keys = ipKey
|
||||
? [`email:${input.email.toLowerCase()}`, ipKey]
|
||||
: [`email:${input.email.toLowerCase()}`];
|
||||
const rl = await authRateLimiter(keys);
|
||||
if (!rl.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
@@ -78,12 +82,19 @@ export const authRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rl = await authRateLimiter(input.token);
|
||||
if (!rl.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many password reset attempts. Please wait before trying again.",
|
||||
});
|
||||
// Rate-limit keyed on IP (token is always new so token-keying is a no-op).
|
||||
// We cannot key on the resolved email before the token lookup; fall back
|
||||
// to IP-only here and apply an email-keyed limit AFTER the successful
|
||||
// lookup to bound per-email brute-force.
|
||||
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
|
||||
if (ipKey) {
|
||||
const rl = await authRateLimiter(ipKey);
|
||||
if (!rl.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many password reset attempts. Please wait before trying again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const record = await ctx.db.passwordResetToken.findUnique({
|
||||
@@ -103,6 +114,17 @@ export const authRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." });
|
||||
}
|
||||
|
||||
// Second-layer limit keyed on the resolved email, so a targeted
|
||||
// attacker cannot exhaust reset attempts for a known user even if
|
||||
// they cycle source IPs.
|
||||
const emailRl = await authRateLimiter(`email-reset:${record.email.toLowerCase()}`);
|
||||
if (!emailRl.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many password reset attempts. Please wait before trying again.",
|
||||
});
|
||||
}
|
||||
|
||||
const { hash } = await import("@node-rs/argon2");
|
||||
const passwordHash = await hash(input.password);
|
||||
|
||||
|
||||
@@ -86,12 +86,15 @@ export const inviteRouter = createTRPCRouter({
|
||||
getInvite: publicProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const rl = await authRateLimiter(input.token);
|
||||
if (!rl.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many attempts. Please wait before trying again.",
|
||||
});
|
||||
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
|
||||
if (ipKey) {
|
||||
const rl = await authRateLimiter(ipKey);
|
||||
if (!rl.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many attempts. Please wait before trying again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const invite = await ctx.db.inviteToken.findUnique({
|
||||
@@ -115,12 +118,15 @@ export const inviteRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rl = await authRateLimiter(input.token);
|
||||
if (!rl.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many attempts. Please wait before trying again.",
|
||||
});
|
||||
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
|
||||
if (ipKey) {
|
||||
const rl = await authRateLimiter(ipKey);
|
||||
if (!rl.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many attempts. Please wait before trying again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const invite = await ctx.db.inviteToken.findUnique({
|
||||
|
||||
@@ -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." });
|
||||
}
|
||||
|
||||
|
||||
+36
-30
@@ -19,6 +19,8 @@ export interface TRPCContext {
|
||||
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
|
||||
roleDefaults: Record<string, PermissionKey[]> | null;
|
||||
requestId?: string;
|
||||
/** Client IP extracted from X-Forwarded-For / X-Real-IP. Null if trust-proxy is off or header absent. */
|
||||
clientIp: string | null;
|
||||
}
|
||||
|
||||
// Cache role defaults for 60 seconds to avoid DB hit on every request
|
||||
@@ -53,12 +55,14 @@ export function createTRPCContext(opts: {
|
||||
session: Session | null;
|
||||
dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null;
|
||||
roleDefaults?: Record<string, PermissionKey[]> | null;
|
||||
clientIp?: string | null;
|
||||
}): TRPCContext {
|
||||
return {
|
||||
session: opts.session,
|
||||
db: prisma,
|
||||
dbUser: opts.dbUser ?? null,
|
||||
roleDefaults: opts.roleDefaults ?? null,
|
||||
clientIp: opts.clientIp ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,8 +74,7 @@ const t = initTRPC.context<TRPCContext>().create({
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -147,34 +150,37 @@ if (process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] === "prod
|
||||
* Protected procedure — requires authenticated session AND a valid DB user record.
|
||||
* This prevents stale sessions from accessing data after the DB user is deleted.
|
||||
*/
|
||||
export const protectedProcedure = t.procedure.use(withPrismaErrors).use(withLogging).use(async ({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
|
||||
}
|
||||
if (!ctx.dbUser) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" });
|
||||
}
|
||||
|
||||
// Rate limit by user ID
|
||||
if (!isE2eTestMode) {
|
||||
const rateLimitResult = await apiRateLimiter(ctx.dbUser.id);
|
||||
if (!rateLimitResult.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`,
|
||||
});
|
||||
export const protectedProcedure = t.procedure
|
||||
.use(withPrismaErrors)
|
||||
.use(withLogging)
|
||||
.use(async ({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
|
||||
}
|
||||
if (!ctx.dbUser) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" });
|
||||
}
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
session: ctx.session,
|
||||
user: ctx.session.user,
|
||||
dbUser: ctx.dbUser,
|
||||
},
|
||||
// Rate limit by user ID
|
||||
if (!isE2eTestMode) {
|
||||
const rateLimitResult = await apiRateLimiter(ctx.dbUser.id);
|
||||
if (!rateLimitResult.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
session: ctx.session,
|
||||
user: ctx.session.user,
|
||||
dbUser: ctx.dbUser,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Resource overview procedure — requires broad people-directory visibility.
|
||||
@@ -191,8 +197,8 @@ export const resourceOverviewProcedure = protectedProcedure.use(({ ctx, next })
|
||||
);
|
||||
|
||||
if (
|
||||
!permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|
||||
&& !permissions.has(PermissionKey.MANAGE_RESOURCES)
|
||||
!permissions.has(PermissionKey.VIEW_ALL_RESOURCES) &&
|
||||
!permissions.has(PermissionKey.MANAGE_RESOURCES)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
@@ -280,7 +286,7 @@ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
*/
|
||||
export function requirePermission(
|
||||
ctx: { permissions: Set<PermissionKey> },
|
||||
key: PermissionKey
|
||||
key: PermissionKey,
|
||||
): void {
|
||||
if (!ctx.permissions.has(key)) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: `Permission required: ${key}` });
|
||||
|
||||
Reference in New Issue
Block a user