From fa3fdd1a1c118c83f7cb610b58e684c340c1d007 Mon Sep 17 00:00:00 2001 From: JetSprow Date: Wed, 29 Apr 2026 11:31:04 +1000 Subject: [PATCH] fix: rate limit email send actions --- src/actions/admin/settings.ts | 28 ++++++++++++++++++++++++++ src/actions/auth/email.ts | 38 +++++++++++++++++++++++++---------- src/actions/user/account.ts | 25 +++++++++++++++++++++++ 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index af9501b..6a62a6e 100644 --- a/src/actions/admin/settings.ts +++ b/src/actions/admin/settings.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; +import { rateLimit } from "@/lib/rate-limit"; import { requireAdmin } from "@/lib/require-auth"; import { actorFromSession, recordAuditLog } from "@/services/audit"; import { getAppConfig } from "@/services/app-config"; @@ -38,6 +39,9 @@ const settingsSchema = z.object({ smtpFromEmail: z.string().trim().email("发件邮箱格式不正确").optional().or(z.literal("")), }); +const SMTP_TEST_LIMIT = 5; +const SMTP_TEST_WINDOW_SECONDS = 10 * 60; + const smtpTestEmailSchema = z.string().trim().email("请输入正确的测试邮箱"); const smtpTestSettingsSchema = settingsSchema.extend({ smtpTestEmail: smtpTestEmailSchema, @@ -62,6 +66,18 @@ function formatActionError(error: unknown, fallback: string) { return fallback; } +async function assertSmtpTestRateLimit(userId: string) { + const { success } = await rateLimit( + `ratelimit:smtp-test:${userId}`, + SMTP_TEST_LIMIT, + SMTP_TEST_WINDOW_SECONDS, + ); + + if (!success) { + throw new Error("测试发信过于频繁,请稍后再试"); + } +} + function buildSettingsUpdate(parsed: z.infer, current: Awaited>) { const smtpEnabled = parsed.smtpEnabled === "true"; const emailVerificationRequired = parsed.emailVerificationRequired === "true"; @@ -164,9 +180,11 @@ export async function saveAppSettings(formData: FormData): Promise { let parsed: z.infer; let next: Awaited>; + let adminUserId = ""; try { const session = await requireAdmin(); + adminUserId = session.user.id; parsed = smtpTestSettingsSchema.parse(Object.fromEntries(formData)); next = await persistAppSettings(session, parsed, "测试发信前更新系统设置"); } catch (error) { @@ -177,6 +195,16 @@ export async function testSmtpSettings(formData: FormData): Promise>; + +function getClientIp(headerList: HeaderList) { + return headerList.get("x-forwarded-for")?.split(",")[0]?.trim() + || headerList.get("x-real-ip")?.trim() + || "unknown"; +} + +async function requestEmailContext(action: string, email: string) { const headerList = await headers(); + const ip = getClientIp(headerList); + const [emailLimit, ipLimit] = await Promise.all([ + rateLimit(`ratelimit:${action}:${email}`, EMAIL_ACTION_LIMIT, EMAIL_ACTION_WINDOW_SECONDS), + rateLimit(`ratelimit:${action}:ip:${ip}`, EMAIL_ACTION_IP_LIMIT, EMAIL_ACTION_WINDOW_SECONDS), + ]); + + if (!emailLimit.success || !ipLimit.success) { + throw new Error("请求过于频繁,请稍后再试"); + } + return { headers: headerList }; } @@ -33,10 +55,7 @@ async function assertMailAvailable() { export async function requestPasswordReset(formData: FormData) { const parsed = emailSchema.parse(Object.fromEntries(formData)); const email = normalizeEmailAddress(parsed.email); - const { success } = await rateLimit(`ratelimit:password-reset:${email}`, 3, 10 * 60); - if (!success) { - throw new Error("请求过于频繁,请稍后再试"); - } + const context = await requestEmailContext("password-reset", email); await assertMailAvailable(); const user = await prisma.user.findUnique({ @@ -48,7 +67,7 @@ export async function requestPasswordReset(formData: FormData) { await sendPasswordResetEmail({ userId: user.id, email: user.email, - ...(await requestContext()), + ...context, }); } } @@ -56,10 +75,7 @@ export async function requestPasswordReset(formData: FormData) { export async function requestRegistrationVerification(formData: FormData) { const parsed = emailSchema.parse(Object.fromEntries(formData)); const email = normalizeEmailAddress(parsed.email); - const { success } = await rateLimit(`ratelimit:email-verify:${email}`, 3, 10 * 60); - if (!success) { - throw new Error("请求过于频繁,请稍后再试"); - } + const context = await requestEmailContext("email-verify", email); await assertMailAvailable(); const user = await prisma.user.findUnique({ @@ -71,7 +87,7 @@ export async function requestRegistrationVerification(formData: FormData) { await sendRegistrationVerificationEmail({ userId: user.id, email: user.email, - ...(await requestContext()), + ...context, }); } } diff --git a/src/actions/user/account.ts b/src/actions/user/account.ts index 091d16b..690c344 100644 --- a/src/actions/user/account.ts +++ b/src/actions/user/account.ts @@ -6,6 +6,7 @@ import { headers } from "next/headers"; import { randomBytes } from "crypto"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; +import { rateLimit } from "@/lib/rate-limit"; import { normalizeEmailAddress, sendEmailChangeConfirmation } from "@/services/email"; import { requireAuth } from "@/lib/require-auth"; @@ -23,6 +24,28 @@ const emailChangeSchema = z.object({ email: z.string().trim().email("请输入正确的新邮箱"), }); +const EMAIL_CHANGE_LIMIT = 3; +const EMAIL_CHANGE_WINDOW_SECONDS = 10 * 60; + +async function assertEmailChangeRateLimit(userId: string, email: string) { + const [userLimit, targetLimit] = await Promise.all([ + rateLimit( + `ratelimit:account-email-change:user:${userId}`, + EMAIL_CHANGE_LIMIT, + EMAIL_CHANGE_WINDOW_SECONDS, + ), + rateLimit( + `ratelimit:account-email-change:email:${email}`, + EMAIL_CHANGE_LIMIT, + EMAIL_CHANGE_WINDOW_SECONDS, + ), + ]); + + if (!userLimit.success || !targetLimit.success) { + throw new Error("请求过于频繁,请稍后再试"); + } +} + async function generateUniqueInviteCode(): Promise { for (let i = 0; i < 10; i += 1) { const code = randomBytes(4).toString("hex").toUpperCase(); @@ -71,6 +94,8 @@ export async function requestAccountEmailChange(formData: FormData) { throw new Error("这个邮箱已经被其他账户使用"); } + await assertEmailChangeRateLimit(session.user.id, email); + const headerList = await headers(); await sendEmailChangeConfirmation({ userId: session.user.id,