security: rate-limit send and broadcast per user

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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-20 19:48:30 +03:00
parent 185df34500
commit 1f295a831a
No known key found for this signature in database
3 changed files with 156 additions and 0 deletions

View File

@ -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,

View File

@ -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,

131
src/lib/rate-limit.ts Normal file
View File

@ -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<string, Entry>();
// 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;
}