From ee27288c8fb92bf24ee97c9c06a0e0fa60186a8e Mon Sep 17 00:00:00 2001 From: waterlemonnn Date: Sat, 8 Aug 2026 00:03:39 +0700 Subject: [PATCH] fix(newsletter): rate limit signups and stop resending welcome email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/newsletter had no rate limit, unlike every other public write route in the app, making it the cheapest endpoint to abuse for enqueueing unlimited welcome emails to arbitrary addresses. Apply consumeSharedRateLimit per IP, same as teams/waitlist. Also guard the welcome email so a repeat signup for an already-active subscriber doesn't re-send it — a second amplification path the rate limit alone wouldn't close. --- app/api/newsletter/route.test.ts | 146 +++++++++++++++++++++++++++++++ app/api/newsletter/route.ts | 38 +++++++- 2 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 app/api/newsletter/route.test.ts diff --git a/app/api/newsletter/route.test.ts b/app/api/newsletter/route.test.ts new file mode 100644 index 0000000..d47a8b1 --- /dev/null +++ b/app/api/newsletter/route.test.ts @@ -0,0 +1,146 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hashIp = vi.fn(() => "hashed-ip"); +const getClientIp = vi.fn(() => "127.0.0.1"); +const consumeSharedRateLimit = vi.fn(async () => ({ + allowed: true, + remaining: 2, + resetAt: new Date().toISOString(), + currentCount: 1, +})); +const from = vi.fn(); +const sendNewsletterWelcome = vi.fn(async () => {}); + +vi.mock("@/lib/supabase/admin", () => ({ + createSupabaseAdminClient: () => ({ from }), +})); + +vi.mock("@/lib/utils/hash", () => ({ + hashIp, + getClientIp, +})); + +vi.mock("@/lib/rate-limit/shared", () => ({ + consumeSharedRateLimit, +})); + +vi.mock("@/lib/observability/events", () => ({ + logEvent: vi.fn(), +})); + +vi.mock("@/lib/resend/newsletter", () => ({ + sendNewsletterWelcome, +})); + +function createRequest(body: unknown) { + return new NextRequest("http://localhost/api/newsletter", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function mockNewSubscriber() { + from.mockReturnValue({ + select: () => ({ + eq: () => ({ + maybeSingle: async () => ({ data: null }), + }), + }), + insert: () => ({ + select: () => ({ + single: async () => ({ + data: { unsubscribe_token: "token-123" }, + error: null, + }), + }), + }), + }); +} + +function mockExistingSubscriber(status: string) { + const update = vi.fn(() => ({ + eq: async () => ({ error: null }), + })); + from.mockReturnValue({ + select: () => ({ + eq: () => ({ + maybeSingle: async () => ({ + data: { id: "sub-1", status, unsubscribe_token: "token-existing" }, + }), + }), + }), + update, + }); + return update; +} + +describe("POST /api/newsletter", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + consumeSharedRateLimit.mockResolvedValue({ + allowed: true, + remaining: 2, + resetAt: new Date().toISOString(), + currentCount: 1, + }); + }); + + it("subscribes a new email and sends the welcome email", async () => { + mockNewSubscriber(); + + const { POST } = await import("./route"); + const response = await POST(createRequest({ email: "new@example.com" })); + + expect(response.status).toBe(200); + expect(sendNewsletterWelcome).toHaveBeenCalledWith( + "new@example.com", + "token-123", + ); + }); + + it("does not resend the welcome email to an already-active subscriber", async () => { + mockExistingSubscriber("active"); + + const { POST } = await import("./route"); + const response = await POST( + createRequest({ email: "existing@example.com" }), + ); + + expect(response.status).toBe(200); + expect(sendNewsletterWelcome).not.toHaveBeenCalled(); + }); + + it("resends the welcome email when reactivating an unsubscribed address", async () => { + mockExistingSubscriber("unsubscribed"); + + const { POST } = await import("./route"); + const response = await POST( + createRequest({ email: "existing@example.com" }), + ); + + expect(response.status).toBe(200); + expect(sendNewsletterWelcome).toHaveBeenCalledWith( + "existing@example.com", + "token-existing", + ); + }); + + it("returns 429 and skips the database and email when rate limited", async () => { + consumeSharedRateLimit.mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: new Date().toISOString(), + currentCount: 3, + }); + + const { POST } = await import("./route"); + const response = await POST(createRequest({ email: "spam@example.com" })); + + expect(response.status).toBe(429); + expect(from).not.toHaveBeenCalled(); + expect(sendNewsletterWelcome).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/newsletter/route.ts b/app/api/newsletter/route.ts index c7dbffb..76a0995 100644 --- a/app/api/newsletter/route.ts +++ b/app/api/newsletter/route.ts @@ -2,10 +2,36 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; import { sendNewsletterWelcome } from "@/lib/resend/newsletter"; +import { hashIp, getClientIp } from "@/lib/utils/hash"; +import { logEvent } from "@/lib/observability/events"; +import { consumeSharedRateLimit } from "@/lib/rate-limit/shared"; const schema = z.object({ email: z.string().email() }); +const WINDOW_SECONDS = 60 * 60; +const MAX_REQUESTS = 3; + export async function POST(req: NextRequest) { + const ipHash = hashIp(getClientIp(req.headers)); + + const rateLimit = await consumeSharedRateLimit( + `newsletter:${ipHash}`, + WINDOW_SECONDS, + MAX_REQUESTS, + ); + + if (!rateLimit.allowed) { + logEvent({ + event: "newsletter.rate_limited", + level: "warn", + ipHash, + }); + return NextResponse.json( + { error: "Too many signup requests. Try again later." }, + { status: 429 }, + ); + } + let body: unknown; try { body = await req.json(); @@ -31,6 +57,7 @@ export async function POST(req: NextRequest) { .maybeSingle(); let token: string | undefined = existing?.unsubscribe_token; + let shouldSendWelcome = !existing; if (existing) { if (existing.status !== "active") { @@ -38,6 +65,7 @@ export async function POST(req: NextRequest) { .from("newsletter_subscribers") .update({ status: "active" }) .eq("id", existing.id); + shouldSendWelcome = true; } } else { const { data: inserted, error } = await supabase @@ -56,10 +84,12 @@ export async function POST(req: NextRequest) { } // Welcome email is best-effort; subscription already succeeded. - try { - if (token) await sendNewsletterWelcome(email, token); - } catch (e) { - console.error("[newsletter] welcome email failed:", e); + if (shouldSendWelcome) { + try { + if (token) await sendNewsletterWelcome(email, token); + } catch (e) { + console.error("[newsletter] welcome email failed:", e); + } } return NextResponse.json({ ok: true });