feat: añade limitador de tasa por usuario (15/min) y tests

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
pull/1/head
borja 2 months ago
parent 432409e246
commit 9668802cbe

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

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

@ -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<string, { tokens: number; last: number }>();
private static readonly lastNotifyAt = new Map<string, number>();
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();
}
}

@ -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
});
});
Loading…
Cancel
Save