From 1f295a831a83e3c87e64279f15fe1fe56f4e3480 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Mon, 20 Apr 2026 19:48:30 +0300 Subject: [PATCH] security: rate-limit send and broadcast per user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fork running WaCRM publicly had no server-side guard on the WhatsApp send endpoints — a compromised session or an aggressive UI loop could call /api/whatsapp/send or /broadcast as fast as the network allowed and blow through Meta's per-number quota (or worse, get the number suspended). This PR adds a minimal in-memory fixed-window rate limiter keyed by user id. Budgets (tuned for a template default — forkers can edit): - /api/whatsapp/send 60 per minute per user (1/s sustained) - /api/whatsapp/broadcast 5 per minute per user (campaign dispatch rate, not fan-out) Behaviour: - Check runs right after the Supabase auth check; unauthenticated calls 401 as before and never consume a bucket entry. - When a user exceeds the budget the route returns 429 with Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers. - Send and broadcast buckets are independent; two users are independent. Design trade-off: The limiter lives in a process-local Map. On a single Hostinger Managed Node.js instance — the template's default deployment — that's the whole limit. On a horizontally-scaled deploy (multiple regions, serverless fan-out) each instance has its own Map and the effective rate is N * limit. Swap src/lib/rate-limit.ts for a Redis/Upstash/Durable-Objects backend if you scale past one instance; keep the checkRateLimit return shape and the call sites don't move. Memory is self-cleaning: a 1-in-1000 opportunistic sweep drops expired keys without a background timer, so the Map stays bounded without running anything outside the request path. Smoke test (tsx): - 7 broadcasts → 5 ok, 2 denied. - Different user → independent bucket. - Same user, different route → independent bucket. - Retry-after = 60s. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/whatsapp/broadcast/route.ts | 13 +++ src/app/api/whatsapp/send/route.ts | 12 +++ src/lib/rate-limit.ts | 131 ++++++++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 src/lib/rate-limit.ts diff --git a/src/app/api/whatsapp/broadcast/route.ts b/src/app/api/whatsapp/broadcast/route.ts index ec5fe4d..b987c84 100644 --- a/src/app/api/whatsapp/broadcast/route.ts +++ b/src/app/api/whatsapp/broadcast/route.ts @@ -8,6 +8,11 @@ import { phoneVariants, isRecipientNotAllowedError, } from '@/lib/whatsapp/phone-utils' +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit' interface BroadcastResult { phone: string @@ -56,6 +61,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + // Per-user broadcast budget. Note: this limits how often a user + // can *start* a campaign, not how many messages go out inside + // one — the fan-out loop below runs without additional gating. + const limit = checkRateLimit(`broadcast:${user.id}`, RATE_LIMITS.broadcast) + if (!limit.success) { + return rateLimitResponse(limit) + } + const body = await request.json() const { recipients: newRecipients, diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index 090b83e..a2dd1ea 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -8,6 +8,11 @@ import { phoneVariants, isRecipientNotAllowedError, } from '@/lib/whatsapp/phone-utils' +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit' export async function POST(request: Request) { try { @@ -25,6 +30,13 @@ export async function POST(request: Request) { ) } + // Per-user rate limit. Bucket key is scoped to this route so + // `/broadcast` has an independent budget. + const limit = checkRateLimit(`send:${user.id}`, RATE_LIMITS.send) + if (!limit.success) { + return rateLimitResponse(limit) + } + const body = await request.json() const { conversation_id, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..71d3667 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,131 @@ +/** + * In-memory per-key rate limiter. + * + * Fixed-window counter (not token bucket): every identifier gets a + * fresh N-request budget each window. Simple, allocation-light, and + * fine for a single-instance VPS — which is how forkers of this + * template will usually deploy. + * + * Trade-off: a single Node process holds the Map, so horizontal scale + * (multiple regions, multiple Hostinger nodes, Vercel serverless fan- + * out) silently defeats the limit. If you scale beyond one instance, + * swap the `check` implementation for Redis / Upstash / Cloudflare + * Durable Objects keeping the same return shape. The call sites won't + * change. + * + * Memory: entries are ~50 bytes each. With LIGHT_SWEEP below, expired + * keys get cleared opportunistically on every ~1 000th call, so a + * healthy instance stays in the low-MB range even with thousands of + * distinct users. No background timer — works in serverless edge + * runtimes that don't keep timers alive across requests. + */ + +import { NextResponse } from 'next/server'; + +export interface RateLimitOptions { + /** Max requests allowed in `windowMs`. */ + limit: number; + /** Window size, milliseconds. */ + windowMs: number; +} + +export interface RateLimitResult { + success: boolean; + /** Requests still allowed in the current window. */ + remaining: number; + /** Unix ms when the bucket refills. */ + reset: number; + limit: number; +} + +interface Entry { + count: number; + resetAt: number; +} + +const buckets = new Map(); + +// Opportunistic cleanup. Running a sweep on every call would be +// quadratic; running it 1-in-N lets the Map self-drain without a +// background timer. +const LIGHT_SWEEP_EVERY = 1000; +let callsSinceSweep = 0; + +function sweepExpired(now: number) { + for (const [k, v] of buckets) { + if (v.resetAt <= now) buckets.delete(k); + } +} + +export function checkRateLimit( + key: string, + { limit, windowMs }: RateLimitOptions, +): RateLimitResult { + const now = Date.now(); + + callsSinceSweep += 1; + if (callsSinceSweep >= LIGHT_SWEEP_EVERY) { + callsSinceSweep = 0; + sweepExpired(now); + } + + const entry = buckets.get(key); + + if (!entry || entry.resetAt <= now) { + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return { success: true, remaining: limit - 1, reset: now + windowMs, limit }; + } + + if (entry.count >= limit) { + return { success: false, remaining: 0, reset: entry.resetAt, limit }; + } + + entry.count += 1; + return { + success: true, + remaining: limit - entry.count, + reset: entry.resetAt, + limit, + }; +} + +/** + * Standard 429 response with the headers clients expect (RFC 6585 + + * draft-ietf-httpapi-ratelimit-headers). Callers just `return` this. + */ +export function rateLimitResponse(result: RateLimitResult): NextResponse { + const retryAfterSec = Math.max(1, Math.ceil((result.reset - Date.now()) / 1000)); + return NextResponse.json( + { + error: 'Rate limit exceeded', + retry_after_seconds: retryAfterSec, + }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfterSec), + 'X-RateLimit-Limit': String(result.limit), + 'X-RateLimit-Remaining': String(result.remaining), + 'X-RateLimit-Reset': String(Math.ceil(result.reset / 1000)), + }, + }, + ); +} + +/** Preconfigured budgets, tweak here not at call sites. */ +export const RATE_LIMITS = { + /** Individual message send. 60/min per user = one per second + * sustained, comfortable for a live human typing. */ + send: { limit: 60, windowMs: 60_000 }, + /** Broadcast dispatch. 5/min per user — even a 1 000-recipient + * broadcast is one call; this caps the rate at which a single user + * can launch campaigns, not the messages inside one. */ + broadcast: { limit: 5, windowMs: 60_000 }, +} as const; + +/** Test-only helper. Clears the in-memory state so unit tests don't + * leak buckets across files. Not wired up in production code. */ +export function __resetRateLimitForTests() { + buckets.clear(); + callsSinceSweep = 0; +}