diff --git a/.env.example b/.env.example index beb7040..39743a7 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,7 @@ NODE_ENV="production" # - Mínimo 10000ms (10s) forzado en development # - Formato: número sin comillas # GROUP_SYNC_INTERVAL_MS=86400000 + +# Rate limiting (opcional; desactivado en tests; por defecto 15/min) +# RATE_LIMIT_PER_MIN=15 +# RATE_LIMIT_BURST=15 diff --git a/src/server.ts b/src/server.ts index d3d1e20..e2d6fa5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ import { normalizeWhatsAppId, isGroupId } from './utils/whatsapp'; import { ensureUserExists, db } from './db'; import { ContactsService } from './services/contacts'; import { Migrator } from './db/migrator'; +import { RateLimiter } from './services/rate-limit'; // Bun is available globally when running under Bun runtime declare global { @@ -215,6 +216,20 @@ export class WebhookServer { // Forward to command service only if it's a text-ish message and starts with /t or /tarea const messageTextTrimmed = messageText.trim(); if (messageTextTrimmed.startsWith('/tarea') || messageTextTrimmed.startsWith('/t')) { + // Rate limiting básico por usuario (desactivado en tests) + if (process.env.NODE_ENV !== 'test') { + const allowed = RateLimiter.checkAndConsume(normalizedSenderId); + if (!allowed) { + // Notificar como máximo una vez por minuto + if (RateLimiter.shouldNotify(normalizedSenderId)) { + await ResponseQueue.add([{ + recipient: normalizedSenderId, + message: 'Has superado el límite de 15 comandos por minuto. Inténtalo de nuevo en un momento.' + }]); + } + return; + } + } // Extraer menciones desde el mensaje (varios formatos) const mentions = data.message?.contextInfo?.mentionedJid || data.message?.extendedTextMessage?.contextInfo?.mentionedJid diff --git a/src/services/rate-limit.ts b/src/services/rate-limit.ts new file mode 100644 index 0000000..a1e08b6 --- /dev/null +++ b/src/services/rate-limit.ts @@ -0,0 +1,66 @@ +/** + * RateLimiter - Token Bucket en memoria por usuario. + * - Límite global por remitente (ID normalizado). + * - Capacidad y tasa configurables por entorno: + * - RATE_LIMIT_PER_MIN: tokens que se recargan por minuto (default 15) + * - RATE_LIMIT_BURST: capacidad del bucket (default = RATE_LIMIT_PER_MIN) + * - Desactivación en tests: la integración en server evita usar el limiter con NODE_ENV='test'. + */ +export class RateLimiter { + private static readonly buckets = new Map(); + private static readonly lastNotifyAt = new Map(); + private static readonly NOTIFY_COOLDOWN_MS = 60_000; // 1 minuto + + private static get ratePerMin(): number { + const v = Number(process.env.RATE_LIMIT_PER_MIN); + return Number.isFinite(v) && v > 0 ? v : 15; + } + + private static get capacity(): number { + const burst = Number(process.env.RATE_LIMIT_BURST); + if (Number.isFinite(burst) && burst > 0) return burst; + return this.ratePerMin; // por defecto, burst = rate + } + + private static get refillPerMs(): number { + return this.ratePerMin / 60_000; // tokens por ms + } + + static checkAndConsume(userId: string, now: number = Date.now()): boolean { + if (!userId) return true; // por seguridad, no bloquear IDs vacíos + + let bucket = this.buckets.get(userId); + if (!bucket) { + bucket = { tokens: this.capacity, last: now }; + this.buckets.set(userId, bucket); + } + + // Refill lineal en función del tiempo transcurrido + const elapsed = Math.max(0, now - bucket.last); + if (elapsed > 0) { + bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillPerMs); + bucket.last = now; + } + + if (bucket.tokens >= 1) { + bucket.tokens -= 1; + return true; + } + return false; + } + + static shouldNotify(userId: string, now: number = Date.now()): boolean { + const last = this.lastNotifyAt.get(userId) || 0; + if (now - last >= this.NOTIFY_COOLDOWN_MS) { + this.lastNotifyAt.set(userId, now); + return true; + } + return false; + } + + // Solo para tests + static reset(): void { + this.buckets.clear(); + this.lastNotifyAt.clear(); + } +} diff --git a/tests/unit/services/rate-limit.test.ts b/tests/unit/services/rate-limit.test.ts new file mode 100644 index 0000000..ed6ed6f --- /dev/null +++ b/tests/unit/services/rate-limit.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { RateLimiter } from '../../../src/services/rate-limit'; + +describe('RateLimiter - token bucket básico', () => { + beforeEach(() => { + RateLimiter.reset(); + // No dependemos de NODE_ENV aquí; testea la clase aislada. + process.env.RATE_LIMIT_PER_MIN = '15'; + process.env.RATE_LIMIT_BURST = '15'; + }); + + it('permite hasta el burst inicial y bloquea al exceder', () => { + const user = '1234567890'; + const t0 = 0; + + // 15 primeros deben pasar + for (let i = 0; i < 15; i++) { + const ok = RateLimiter.checkAndConsume(user, t0); + expect(ok).toBe(true); + } + + // 16º sin tiempo transcurrido debe bloquear + const denied = RateLimiter.checkAndConsume(user, t0); + expect(denied).toBe(false); + }); + + it('recupera tokens con el tiempo (refill lineal por minuto)', () => { + const user = '1234567890'; + const t0 = 0; + + // Agotar bucket + for (let i = 0; i < 15; i++) { + expect(RateLimiter.checkAndConsume(user, t0)).toBe(true); + } + expect(RateLimiter.checkAndConsume(user, t0)).toBe(false); // agotado + + // Tras 60s debe haberse recuperado capacidad suficiente para permitir de nuevo + const t1 = 60_000; + expect(RateLimiter.checkAndConsume(user, t1)).toBe(true); + + // Y puede consumir varios más (hasta ~15 por minuto) + let count = 1; + while (RateLimiter.checkAndConsume(user, t1)) { + count++; + if (count > 20) break; // seguridad + } + expect(count).toBeGreaterThan(1); + }); + + it('shouldNotify limita el aviso a como mucho 1/min por usuario', () => { + const user = 'u1'; + const t0 = 0; + expect(RateLimiter.shouldNotify(user, t0)).toBe(true); // primera vez notifica + expect(RateLimiter.shouldNotify(user, t0 + 10_000)).toBe(false); // dentro de la ventana + expect(RateLimiter.shouldNotify(user, t0 + 60_000)).toBe(true); // pasado el cooldown + }); +});