fix(security): harden auth reset, rate limiter fallback, and CI secrets

- Move CI_AUTH_SECRET from plaintext to ${{ secrets.CI_AUTH_SECRET }}
- Wrap password reset (update + session kill + token mark) in $transaction
  to prevent stale sessions on partial failure (CWE-613)
- Rate limiter Redis fallback now uses stricter degraded limits
  (maxRequests/10) and logs at error level instead of warn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:03:42 +02:00
parent 98c2554570
commit 110e4ff1aa
5 changed files with 78 additions and 53 deletions
+18 -12
View File
@@ -94,7 +94,10 @@ export const authRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND", message: "Reset link not found." });
}
if (record.usedAt) {
throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has already been used." });
throw new TRPCError({
code: "BAD_REQUEST",
message: "This reset link has already been used.",
});
}
if (record.expiresAt < new Date()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." });
@@ -103,19 +106,22 @@ export const authRouter = createTRPCRouter({
const { hash } = await import("@node-rs/argon2");
const passwordHash = await hash(input.password);
const updatedUser = await ctx.db.user.update({
where: { email: record.email },
data: { passwordHash },
select: { id: true },
});
// All three operations must succeed atomically: if session deletion
// fails after the password is already changed, old sessions could
// persist with the new password (CWE-613).
await ctx.db.$transaction(async (tx) => {
const updatedUser = await tx.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 tx.activeSession.deleteMany({ where: { userId: updatedUser.id } });
await ctx.db.passwordResetToken.update({
where: { token: input.token },
data: { usedAt: new Date() },
await tx.passwordResetToken.update({
where: { token: input.token },
data: { usedAt: new Date() },
});
});
return { success: true };