From 64f50b2ddeb57215f1cd9aa554c98aa3a15acaf9 Mon Sep 17 00:00:00 2001
From: remco
Date: Thu, 2 Jul 2026 14:54:25 +0200
Subject: [PATCH] fix: resolve auth security issues - 2FA require TOTP on
disable, rate limiting, timing-safe login, token expiry check
---
src/actions/auth-precheck.ts | 13 ++++++++++++-
src/actions/email-verify.ts | 6 ++++--
src/actions/twofactor.ts | 28 +++++++++++++++++++++++++++-
src/app/settings/2fa/page.tsx | 8 +++++++-
src/lib/api-auth.ts | 7 ++++---
src/lib/auth.ts | 10 +++++++++-
6 files changed, 63 insertions(+), 9 deletions(-)
diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts
index 5cfaf3741c..60718f6292 100644
--- a/src/actions/auth-precheck.ts
+++ b/src/actions/auth-precheck.ts
@@ -2,6 +2,7 @@
import { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
+import { clientIp, rateLimit } from "@/lib/rate-limit";
import { env } from "@/env";
export type PrecheckResult = "ok" | "invalid" | "twofactor";
@@ -18,6 +19,8 @@ export async function precheckLogin(
const p = String(password ?? "");
if (!u || !p) return "invalid";
+ if (!rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000).ok) return "invalid";
+
let user: { password: string; twoFactorConfirmedAt: Date | null } | null = null;
try {
user = await prisma.user.findUnique({
@@ -27,7 +30,15 @@ export async function precheckLogin(
} catch {
return "invalid";
}
- if (!user) return "invalid";
+ if (!user) {
+ // Prevent timing-based enumeration: always run a dummy hash check.
+ await checkLogin(
+ p,
+ "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
+ { convertPasswords: false },
+ );
+ return "invalid";
+ }
const res = await checkLogin(p, user.password, {
convertPasswords: env.CONVERT_PASSWORDS,
diff --git a/src/actions/email-verify.ts b/src/actions/email-verify.ts
index 2c7537a240..5d972a71af 100644
--- a/src/actions/email-verify.ts
+++ b/src/actions/email-verify.ts
@@ -14,9 +14,11 @@ import { siteSettings } from "@/lib/services/site-settings";
// The token is therefore deterministic per (email, secret) pair and stays valid
// until the account's mail_verified flips to '1' (after which /verify no-ops).
-/** Secret mixed into the digest. Falls back to AUTH_SECRET, then a constant. */
+/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */
function verifySecret(): string {
- return env.APP_KEY || env.AUTH_SECRET || "atom-cms-verify";
+ const secret = env.APP_KEY || env.AUTH_SECRET;
+ if (!secret) throw new Error("APP_KEY or AUTH_SECRET must be set for email verification");
+ return secret;
}
/** Compute the verification token for an email (lowercased + trimmed). */
diff --git a/src/actions/twofactor.ts b/src/actions/twofactor.ts
index 8e81e75972..90cc5bc9aa 100644
--- a/src/actions/twofactor.ts
+++ b/src/actions/twofactor.ts
@@ -6,6 +6,7 @@ import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
+import { clientIp, rateLimit } from "@/lib/rate-limit";
import { env } from "@/env";
async function sessionUserId(): Promise {
@@ -31,6 +32,9 @@ export async function beginTwoFactor(): Promise {
export async function confirmTwoFactor(formData: FormData): Promise {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
+
+ if (!rateLimit(`2fa-confirm:${id}`, 5, 30_000).ok) redirect("/settings/2fa?error=ratelimit");
+
const code = String(formData.get("code") ?? "").trim();
const user = await prisma.user.findUnique({
@@ -53,8 +57,30 @@ export async function confirmTwoFactor(formData: FormData): Promise {
redirect("/settings/2fa?enabled=1");
}
-export async function disableTwoFactor(): Promise {
+export async function disableTwoFactor(formData: FormData): Promise {
const id = await sessionUserId();
+ if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
+
+ if (!rateLimit(`2fa-disable:${id}`, 5, 30_000).ok) redirect("/settings/2fa?error=ratelimit");
+
+ const code = String(formData.get("code") ?? "").trim();
+
+ const user = await prisma.user.findUnique({
+ where: { id },
+ select: { twoFactorSecret: true },
+ });
+
+ let ok = false;
+ if (user?.twoFactorSecret && code) {
+ try {
+ const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret);
+ ok = verifyTotp(code, secret);
+ } catch {
+ ok = false;
+ }
+ }
+ if (!ok) redirect("/settings/2fa?error=badcode");
+
await prisma.user.update({
where: { id },
data: {
diff --git a/src/app/settings/2fa/page.tsx b/src/app/settings/2fa/page.tsx
index dcd63f6893..8101c03baf 100644
--- a/src/app/settings/2fa/page.tsx
+++ b/src/app/settings/2fa/page.tsx
@@ -73,6 +73,11 @@ export default async function TwoFactorPage({
{t("badCode")}
) : null}
+ {sp.error === "ratelimit" ? (
+
+ {t("rateLimit")}
+
+ ) : null}
{!hasAppKey ? (
@@ -84,7 +89,8 @@ export default async function TwoFactorPage({
{t("isEnabled")} {t("onYourAccount")}
-