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 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 21:37:56 +02:00
parent ebeb180f3f
commit 3452464809
2 changed files with 29 additions and 1 deletions
+23 -1
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../trpc.js"; import { createTRPCRouter, publicProcedure } from "../trpc.js";
import { getAppBaseUrl } from "../lib/app-base-url.js"; import { getAppBaseUrl } from "../lib/app-base-url.js";
import { sendEmail } from "../lib/email.js"; import { sendEmail } from "../lib/email.js";
import { authRateLimiter } from "../middleware/rate-limit.js";
const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -26,6 +27,14 @@ export const authRouter = createTRPCRouter({
requestPasswordReset: publicProcedure requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() })) .input(z.object({ email: z.string().email() }))
.mutation(async ({ ctx, input }) => { .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({ const user = await ctx.db.user.findUnique({
where: { email: input.email }, where: { email: input.email },
select: { id: true, email: true }, select: { id: true, email: true },
@@ -69,6 +78,14 @@ export const authRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ ctx, input }) => { .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({ const record = await ctx.db.passwordResetToken.findUnique({
where: { token: input.token }, where: { token: input.token },
}); });
@@ -86,11 +103,16 @@ export const authRouter = createTRPCRouter({
const { hash } = await import("@node-rs/argon2"); const { hash } = await import("@node-rs/argon2");
const passwordHash = await hash(input.password); const passwordHash = await hash(input.password);
await ctx.db.user.update({ const updatedUser = await ctx.db.user.update({
where: { email: record.email }, where: { email: record.email },
data: { passwordHash }, 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({ await ctx.db.passwordResetToken.update({
where: { token: input.token }, where: { token: input.token },
data: { usedAt: new Date() }, data: { usedAt: new Date() },
@@ -171,6 +171,10 @@ export async function setUserPassword(
data: { passwordHash }, 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({ audit({
entityType: "User", entityType: "User",
entityId: user.id, entityId: user.id,
@@ -381,6 +385,7 @@ export async function setUserPermissions(
const user = await ctx.db.user.update({ const user = await ctx.db.user.update({
where: { id: input.userId }, where: { id: input.userId },
data: { permissionOverrides: input.overrides ?? Prisma.DbNull }, data: { permissionOverrides: input.overrides ?? Prisma.DbNull },
select: { id: true, name: true, email: true, permissionOverrides: true },
}); });
audit({ audit({
@@ -414,6 +419,7 @@ export async function resetUserPermissions(
const updated = await ctx.db.user.update({ const updated = await ctx.db.user.update({
where: { id: input.userId }, where: { id: input.userId },
data: { permissionOverrides: Prisma.DbNull }, data: { permissionOverrides: Prisma.DbNull },
select: { id: true, name: true, email: true, permissionOverrides: true },
}); });
audit({ audit({