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
+ {t("rateLimit")} +
+ ) : null} {!hasAppKey ? (@@ -84,7 +89,8 @@ export default async function TwoFactorPage({
{t("isEnabled")} {t("onYourAccount")}
-