mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #69 from ArnasDon/security/rate-limit-send-broadcast
security: rate-limit send and broadcast per user
This commit is contained in:
commit
460b5bf259
@ -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,
|
||||
|
||||
@ -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
131
src/lib/rate-limit.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user