security: bound password inputs, configure pino redact, patch deps (#36 #46 #58)

#36 CRITICAL: add .max(128) to all password Zod schemas to prevent
Argon2-based DoS from unbounded password strings.

#46 HIGH: configure pino redact paths so passwords/tokens/cookies/TOTP
secrets are never serialized in logs.

#58 MEDIUM: upgrade dompurify to ^3.4.0 and add pnpm overrides for
brace-expansion (>=5.0.5) and esbuild (>=0.25.0) to patch known CVEs.
Vite moderate (path traversal, dev-only) remains — requires vitest 3.x
major upgrade, deferred.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 08:13:25 +02:00
parent 0ef9add935
commit 534945f6e3
8 changed files with 110 additions and 304 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ export const authRouter = createTRPCRouter({
.input(
z.object({
token: z.string().min(1),
password: z.string().min(12, "Password must be at least 12 characters."),
password: z.string().min(12, "Password must be at least 12 characters.").max(128),
}),
)
.mutation(async ({ ctx, input }) => {
+13 -6
View File
@@ -99,8 +99,10 @@ export const inviteRouter = createTRPCRouter({
select: { email: true, role: true, expiresAt: true, usedAt: true },
});
if (!invite) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found." });
if (invite.usedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has already been used." });
if (invite.expiresAt < new Date()) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has expired." });
if (invite.usedAt)
throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has already been used." });
if (invite.expiresAt < new Date())
throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has expired." });
return { email: invite.email, role: invite.role };
}),
@@ -109,7 +111,7 @@ export const inviteRouter = createTRPCRouter({
.input(
z.object({
token: z.string(),
password: z.string().min(12, "Password must be at least 12 characters."),
password: z.string().min(12, "Password must be at least 12 characters.").max(128),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -125,13 +127,18 @@ export const inviteRouter = createTRPCRouter({
where: { token: input.token },
});
if (!invite) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found." });
if (invite.usedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has already been used." });
if (invite.expiresAt < new Date()) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has expired." });
if (invite.usedAt)
throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has already been used." });
if (invite.expiresAt < new Date())
throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has expired." });
// Check if user already exists
const existing = await ctx.db.user.findUnique({ where: { email: invite.email } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: "An account with this email already exists." });
throw new TRPCError({
code: "CONFLICT",
message: "An account with this email already exists.",
});
}
const { hash } = await import("@node-rs/argon2");
@@ -10,12 +10,12 @@ export const CreateUserInputSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
password: z.string().min(12),
password: z.string().min(12).max(128),
});
export const SetUserPasswordInputSchema = z.object({
userId: z.string(),
password: z.string().min(12, "Password must be at least 12 characters"),
password: z.string().min(12, "Password must be at least 12 characters").max(128),
});
export const UpdateUserRoleInputSchema = z.object({
@@ -289,10 +289,7 @@ export async function linkUserResource(
const linkResult = await ctx.db.resource.updateMany({
where: {
id: input.resourceId,
OR: [
{ userId: null },
{ userId: input.userId },
],
OR: [{ userId: null }, { userId: input.userId }],
},
data: { userId: input.userId },
});
@@ -393,7 +390,10 @@ export async function setUserPermissions(
entityId: input.userId,
entityName: `${before.name} (${before.email})`,
action: "UPDATE",
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<string, unknown>,
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<
string,
unknown
>,
after: { permissionOverrides: input.overrides } as unknown as Record<string, unknown>,
summary: input.overrides
? `Set permission overrides (granted: ${input.overrides.granted?.length ?? 0}, denied: ${input.overrides.denied?.length ?? 0})`
@@ -427,7 +427,10 @@ export async function resetUserPermissions(
entityId: input.userId,
entityName: `${before.name} (${before.email})`,
action: "UPDATE",
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<string, unknown>,
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<
string,
unknown
>,
after: { permissionOverrides: null } as unknown as Record<string, unknown>,
summary: "Reset permission overrides to role defaults",
});
@@ -464,7 +467,10 @@ export async function deactivateUser(
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
if (ctx.dbUser!.id === input.userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot deactivate your own account." });
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot deactivate your own account.",
});
}
const user = await findUniqueOrThrow(
@@ -479,7 +485,10 @@ export async function deactivateUser(
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already inactive." });
}
await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: false, deletedAt: new Date() } });
await ctx.db.user.update({
where: { id: input.userId },
data: { isActive: false, deletedAt: new Date() },
});
// Invalidate all existing sessions so the user is logged out immediately
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
@@ -512,7 +521,10 @@ export async function reactivateUser(
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already active." });
}
await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: true, deletedAt: null } });
await ctx.db.user.update({
where: { id: input.userId },
data: { isActive: true, deletedAt: null },
});
audit({
entityType: "User",