From 3452464809ef626582aebdad94c82a49ba92b5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 21:37:56 +0200 Subject: [PATCH] fix(security): invalidate sessions on password change and remove hash from permission API responses - setUserPassword and resetPassword now call activeSession.deleteMany after updating the passwordHash, so any pre-change sessions are immediately revoked (CWE-613 session fixation after credential change) - setUserPermissions and resetUserPermissions now use explicit Prisma select to exclude passwordHash and totpSecret from the returned user object Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/router/auth.ts | 24 ++++++++++++++++++- .../api/src/router/user-procedure-support.ts | 6 +++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts index fa941e3..93ba61c 100644 --- a/packages/api/src/router/auth.ts +++ b/packages/api/src/router/auth.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc.js"; import { getAppBaseUrl } from "../lib/app-base-url.js"; import { sendEmail } from "../lib/email.js"; +import { authRateLimiter } from "../middleware/rate-limit.js"; const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour @@ -26,6 +27,14 @@ export const authRouter = createTRPCRouter({ requestPasswordReset: publicProcedure .input(z.object({ email: z.string().email() })) .mutation(async ({ ctx, input }) => { + const rl = await authRateLimiter(input.email); + if (!rl.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many password reset attempts. Please wait before trying again.", + }); + } + const user = await ctx.db.user.findUnique({ where: { email: input.email }, select: { id: true, email: true }, @@ -69,6 +78,14 @@ 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.", + }); + } + const record = await ctx.db.passwordResetToken.findUnique({ where: { token: input.token }, }); @@ -86,11 +103,16 @@ export const authRouter = createTRPCRouter({ const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); - await ctx.db.user.update({ + const updatedUser = await ctx.db.user.update({ where: { email: record.email }, data: { passwordHash }, + select: { id: true }, }); + // Invalidate all active sessions so any session obtained before the + // password reset cannot be reused (CWE-613). + await ctx.db.activeSession.deleteMany({ where: { userId: updatedUser.id } }); + await ctx.db.passwordResetToken.update({ where: { token: input.token }, data: { usedAt: new Date() }, diff --git a/packages/api/src/router/user-procedure-support.ts b/packages/api/src/router/user-procedure-support.ts index e3d6b6c..8685ae6 100644 --- a/packages/api/src/router/user-procedure-support.ts +++ b/packages/api/src/router/user-procedure-support.ts @@ -171,6 +171,10 @@ export async function setUserPassword( data: { passwordHash }, }); + // Invalidate all active sessions so any compromised session cannot be + // reused after the password is changed (CWE-613). + await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } }); + audit({ entityType: "User", entityId: user.id, @@ -381,6 +385,7 @@ export async function setUserPermissions( const user = await ctx.db.user.update({ where: { id: input.userId }, data: { permissionOverrides: input.overrides ?? Prisma.DbNull }, + select: { id: true, name: true, email: true, permissionOverrides: true }, }); audit({ @@ -414,6 +419,7 @@ export async function resetUserPermissions( const updated = await ctx.db.user.update({ where: { id: input.userId }, data: { permissionOverrides: Prisma.DbNull }, + select: { id: true, name: true, email: true, permissionOverrides: true }, }); audit({