From 10905277f8fdc0f72c237fdb45ea1e90e63f6867 Mon Sep 17 00:00:00 2001 From: Rigoberto Franco Vizcarra Date: Wed, 29 Jul 2026 14:20:06 -0600 Subject: [PATCH] security: sign Telegram/ManyChat/Twilio webhooks, throttle by IP, hard-stop the budget guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A code audit found that Telegram, Twilio, and ManyChat webhooks accepted any POST with no authenticity check — unlike Meta/WhatsApp, which already verify X-Hub-Signature-256. Anyone who found the Worker URL could forge messages on those channels, including impersonating the owner via OWNER_TELEGRAM_CHAT_ID (not a secret). There was also no rate limiting anywhere, and the monthly AI budget guard only downgraded the model tier — it never stopped spend or alerted the owner. - Telegram: verify X-Telegram-Bot-Api-Secret-Token against a new TELEGRAM_WEBHOOK_SECRET (set via setWebhook's secret_token). - ManyChat: verify a new X-Manychat-Secret header (ManyChat doesn't sign natively) against MANYCHAT_WEBHOOK_SECRET. - Twilio: verify X-Twilio-Signature using the existing TWILIO_AUTH_TOKEN — no new secret needed. - All three fail closed, mirroring verifyMetaSignature's existing pattern. - New IP throttle (src/rate-limit.ts, D1-backed, no new binding) on the three webhooks and /kb/reindex, ahead of any signature check. - Budget guard (src/budget.ts) now pauses the bot (same bot_paused switch as the dashboard) and notifies the owner once spend hits 1.5x the monthly budget, instead of only ever downgrading to the cheap tier. Breaking for existing deployments: Telegram and ManyChat webhooks will 403 until TELEGRAM_WEBHOOK_SECRET / MANYCHAT_WEBHOOK_SECRET are configured (see wrangler.toml comments), and `pnpm db:apply:remote` needs a re-run for the new rate_limits table. Twilio needs no config change. pnpm typecheck clean; pnpm test: 469/469 passing (32 new). Co-Authored-By: Claude Sonnet 5 --- src/agent.ts | 17 +++- src/budget.ts | 55 +++++++++++ src/channels/manychat.ts | 17 ++++ src/channels/telegram.ts | 18 ++++ src/channels/twilio.ts | 31 ++++++ src/db/schema.sql | 11 +++ src/env.ts | 11 +++ src/index.ts | 67 +++++++++++-- src/rate-limit.ts | 56 +++++++++++ test/budget.test.ts | 43 +++++++- test/channels/manychat.test.ts | 22 ++++- test/channels/telegram.test.ts | 23 ++++- test/channels/twilio.test.ts | 52 +++++++++- test/index.test.ts | 173 +++++++++++++++++++++++++++++++-- test/rate-limit.test.ts | 57 +++++++++++ wrangler.toml | 18 ++++ 16 files changed, 644 insertions(+), 27 deletions(-) create mode 100644 src/rate-limit.ts create mode 100644 test/rate-limit.test.ts diff --git a/src/agent.ts b/src/agent.ts index dc62f5b..04f45d9 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -13,7 +13,7 @@ import { chunkReply } from "./replies/chunker"; import { pickAdapter } from "./replies/sender"; import { selectModel } from "./upgrade/modelSelector"; import type { Tier } from "./upgrade/modelSelector"; -import { monthIaCostUsd, applyBudgetGuard } from "./budget"; +import { monthIaCostUsd, enforceBudgetGuard } from "./budget"; import { CustomerFactsRepo } from "./db/facts"; import { createModel } from "./llm/provider"; import { costOfUsage } from "./pricing"; @@ -269,16 +269,23 @@ export class SupportAgent extends Agent { lastSearchKbScore: this.state.lastSearchKbScore, }); - // Budget guard: at/over the monthly AI budget the bot keeps answering but - // only on the cheap tier (never goes silent over money). - if (cfg.monthlyBudgetUsd !== undefined && tier !== "fast") { + // Budget guard: at the monthly AI budget the bot downgrades to the cheap + // tier (never goes silent over money); at 1.5x it pauses itself for good + // (enforceBudgetGuard flips the same bot_paused switch the dashboard + // uses) and notifies the owner — a downgrade alone doesn't cap spend. + if (cfg.monthlyBudgetUsd !== undefined) { const spent = await monthIaCostUsd(db); - const guard = applyBudgetGuard(tier, spent, cfg.monthlyBudgetUsd); + const guard = await enforceBudgetGuard(this.env, db, tier, spent, cfg.monthlyBudgetUsd); if (guard.downgraded) { console.warn( `[SupportAgent] monthly budget reached ($${spent.toFixed(2)}/$${cfg.monthlyBudgetUsd}) — downgrading to fast tier`, ); } + if (guard.paused) { + console.error( + `[SupportAgent] monthly budget hard-stop ($${spent.toFixed(2)} >= 1.5x $${cfg.monthlyBudgetUsd}) — bot paused, owner notified`, + ); + } tier = guard.tier; } diff --git a/src/budget.ts b/src/budget.ts index 1a4d4a6..c2c820e 100644 --- a/src/budget.ts +++ b/src/budget.ts @@ -5,10 +5,20 @@ * month-to-date AI spend reaches it, the agent downgrades to the "fast" tier * (cheap model) instead of going silent — the bot keeps answering, it just * stops burning money on the smart model. + * + * A downgrade alone doesn't cap spend — "fast" still costs money, and a burst + * of traffic between two spend checks can blow past the budget anyway. Once + * spend reaches HARD_STOP_MULTIPLIER × the budget, enforceBudgetGuard() pauses + * the bot globally (the same bot_paused switch the owner uses from the panel) + * and notifies the owner — reusing notifyOwner(), the same best-effort + * Telegram/WhatsApp/email channel the watchdog and handoff tool already use. */ import { Db } from "./db/client"; import { costOfUsage, type ModelId } from "./pricing"; import type { Tier } from "./upgrade/modelSelector"; +import type { Env } from "./env"; +import { SettingsRepo, SETTING_KEYS } from "./db/settings"; +import { notifyOwner } from "./tools/handoffHuman"; /** UTC start of the current month (injectable clock for tests). */ export function monthStartMs(now = Date.now()): number { @@ -51,3 +61,48 @@ export function applyBudgetGuard( } return { tier, downgraded: false }; } + +/** Hard-stop threshold: spend at 1.5x the monthly budget pauses the bot. */ +export const HARD_STOP_MULTIPLIER = 1.5; + +export interface BudgetGuardResult { + tier: Tier; + downgraded: boolean; + /** true only the turn that actually flips bot_paused (already-paused → false). */ + paused: boolean; +} + +/** + * Downgrades the tier (applyBudgetGuard) and, once spend reaches + * HARD_STOP_MULTIPLIER × the budget, pauses the bot for good measure — sets + * the same `bot_paused` setting the owner's dashboard toggle uses, so every + * conversation goes silent until the owner reactivates it manually. Idempotent: + * if the bot is already paused, does not re-notify on every message. + */ +export async function enforceBudgetGuard( + env: Env, + db: Db, + tier: Tier, + monthCostUsd: number, + budgetUsd: number | undefined, +): Promise { + const guard = applyBudgetGuard(tier, monthCostUsd, budgetUsd); + if (budgetUsd === undefined || budgetUsd <= 0 || monthCostUsd < budgetUsd * HARD_STOP_MULTIPLIER) { + return { ...guard, paused: false }; + } + + const settings = new SettingsRepo(db); + if ((await settings.get(SETTING_KEYS.botPaused)) === "1") { + return { ...guard, paused: false }; // ya pausado — no volver a notificar + } + + await settings.set(SETTING_KEYS.botPaused, "1"); + await notifyOwner(env, { + reason: "presupuesto de IA excedido", + summary: + `🚨 El bot se pausó solo: el gasto de IA de este mes ($${monthCostUsd.toFixed(2)}) ` + + `superó 1.5x tu presupuesto ($${budgetUsd}). Reactívalo desde el panel (Agente) cuando quieras.`, + ticketId: "budget-guard", + }); + return { ...guard, paused: true }; +} diff --git a/src/channels/manychat.ts b/src/channels/manychat.ts index 50dd4fe..4d13e73 100644 --- a/src/channels/manychat.ts +++ b/src/channels/manychat.ts @@ -1,8 +1,25 @@ import type { ChannelAdapter, IncomingMessage, OutgoingReply } from "./shared"; import type { Env } from "../env"; +import { tokensMatch } from "../http-auth"; const MANYCHAT_API = "https://api.manychat.com/fb"; +/** + * ManyChat no firma sus webhooks salientes (External Request), así que la + * protección es un shared secret que el propio miembro configura como header + * custom (`X-Manychat-Secret`) en el flow de ManyChat, comparado contra + * MANYCHAT_WEBHOOK_SECRET. Fail-closed: sin secret configurado, sin header, o + * si no coincide → false. + */ +export function verifyManychatSecret( + headerValue: string | null | undefined, + env: Env, +): boolean { + const expected = env.MANYCHAT_WEBHOOK_SECRET?.trim(); + if (!expected) return false; + return tokensMatch((headerValue ?? "").trim(), expected); +} + // Aligned with a typical ManyChat/n8n production flow: // - ManyChat posts the subscriber in `id` (NOT `subscriber_id`). // - The text arrives in `last_input_text`. diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 7f6e3da..fd22e3b 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -1,8 +1,26 @@ import type { ChannelAdapter, IncomingMessage, OutgoingReply } from "./shared"; import type { Env } from "../env"; +import { tokensMatch } from "../http-auth"; const TG_API = "https://api.telegram.org/bot"; +/** + * Valida el header `X-Telegram-Bot-Api-Secret-Token` que Telegram manda en + * cada POST cuando el webhook se registró con `secret_token` (ver + * https://core.telegram.org/bots/api#setwebhook). Fail-closed: sin + * TELEGRAM_WEBHOOK_SECRET configurado, o sin header, o si no coincide → false. + * Sin esto, cualquiera que adivine la URL del Worker puede mandar un Update + * falso y el bot lo procesa como si viniera de Telegram. + */ +export function verifyTelegramSecret( + headerValue: string | null | undefined, + env: Env, +): boolean { + const expected = env.TELEGRAM_WEBHOOK_SECRET?.trim(); + if (!expected) return false; + return tokensMatch((headerValue ?? "").trim(), expected); +} + interface TgUpdate { update_id: number; message?: { diff --git a/src/channels/twilio.ts b/src/channels/twilio.ts index f918924..8e9b827 100644 --- a/src/channels/twilio.ts +++ b/src/channels/twilio.ts @@ -1,5 +1,36 @@ import type { ChannelAdapter, IncomingMessage, OutgoingReply } from "./shared"; import type { Env } from "../env"; +import { tokensMatch } from "../http-auth"; + +/** + * Valida `X-Twilio-Signature` (HMAC-SHA1 sobre la URL exacta del webhook + + * cada par clave+valor del POST, ordenado alfabéticamente por clave — el + * algoritmo oficial de Twilio, ver + * https://www.twilio.com/docs/usage/security#validating-requests). Usa + * TWILIO_AUTH_TOKEN, que ya es requerido para que el canal envíe respuestas — + * no hace falta un secret nuevo. Fail-closed: sin token, sin firma, o si no + * coincide → false. + */ +export async function verifyTwilioSignature( + url: string, + params: Record, + signatureHeader: string | null | undefined, + authToken: string | undefined, +): Promise { + if (!authToken || !signatureHeader) return false; + let data = url; + for (const key of Object.keys(params).sort()) data += key + params[key]; + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(authToken), + { name: "HMAC", hash: "SHA-1" }, + false, + ["sign"], + ); + const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data)); + const expected = btoa(String.fromCharCode(...new Uint8Array(sigBuf))); + return tokensMatch(expected, signatureHeader.trim()); +} export const twilioAdapter: ChannelAdapter = { async parseIncoming(request: Request, _env: Env): Promise { diff --git a/src/db/schema.sql b/src/db/schema.sql index f50800a..b9a5a63 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -203,3 +203,14 @@ CREATE TABLE IF NOT EXISTS template_sends ( UNIQUE (campaign_key, conversation_id) ); CREATE INDEX IF NOT EXISTS idx_template_sends_time ON template_sends(sent_at); + +-- Throttle por IP (defensa en profundidad) para rutas expuestas sin sesión: +-- webhooks y /kb/reindex. Un contador por ventana fija (bucket:ip), reseteado +-- cuando cambia window_start. Ver src/rate-limit.ts. La capa principal es una +-- Cloudflare Rate Limiting Rule (fuera de este repo, dashboard de Cloudflare) +-- -- esto cubre el caso sin esa regla configurada. +CREATE TABLE IF NOT EXISTS rate_limits ( + key TEXT PRIMARY KEY, + window_start INTEGER NOT NULL, + count INTEGER NOT NULL +); diff --git a/src/env.ts b/src/env.ts index 84765b3..fd3f1de 100644 --- a/src/env.ts +++ b/src/env.ts @@ -38,7 +38,18 @@ export interface Env { OPENAI_API_KEY?: string; // alternative LLM provider (see LLM_PROVIDER) RESEND_API_KEY?: string; TELEGRAM_BOT_TOKEN?: string; + // secret_token puesto al registrar el webhook (setWebhook); valida el + // header X-Telegram-Bot-Api-Secret-Token en cada POST entrante (fail-closed + // si falta — ver src/channels/telegram.ts verifyTelegramSecret). + TELEGRAM_WEBHOOK_SECRET?: string; MANYCHAT_API_KEY?: string; + // shared secret que el miembro configura como header custom + // (X-Manychat-Secret) en el External Request de su flow de ManyChat; valida + // el origen del webhook (fail-closed si falta — ver + // src/channels/manychat.ts verifyManychatSecret). ManyChat no firma sus + // webhooks nativamente, así que esto es lo único que evita que cualquiera + // mande un POST falso a /webhooks/manychat. + MANYCHAT_WEBHOOK_SECRET?: string; MANYCHAT_CONTENT_TYPE?: "instagram" | "whatsapp" | "telegram" | "messenger"; // ManyChat channel for sendContent; defaults to "instagram" TWILIO_ACCOUNT_SID?: string; TWILIO_AUTH_TOKEN?: string; diff --git a/src/index.ts b/src/index.ts index fe964c8..e1f521c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ import { Hono } from "hono"; import type { Env } from "./env"; import type { ChannelAdapter } from "./channels/shared"; -import { telegramAdapter } from "./channels/telegram"; -import { manychatAdapter } from "./channels/manychat"; -import { twilioAdapter } from "./channels/twilio"; +import { telegramAdapter, verifyTelegramSecret } from "./channels/telegram"; +import { manychatAdapter, verifyManychatSecret } from "./channels/manychat"; +import { twilioAdapter, verifyTwilioSignature } from "./channels/twilio"; import { parseMetaEvents, verifyMetaSignature } from "./channels/meta"; import { parseWhatsAppEvents, serveWhatsAppMedia } from "./channels/whatsapp"; import { adminApp } from "./admin/routes"; @@ -16,6 +16,7 @@ import { detectKind } from "./learn/fieldPath"; import { saveCapture, isLearnMode } from "./learn/mapping"; import { tokensMatch } from "./http-auth"; import { apiApp } from "./api"; +import { isRateLimited, clientIp } from "./rate-limit"; export { SupportAgent } from "./agent"; @@ -54,12 +55,55 @@ async function routeToAgent(c: { req: { raw: Request }; env: Env; text: (t: stri } } -app.post("/webhooks/telegram", (c) => routeToAgent(c, telegramAdapter)); -app.post("/webhooks/manychat", (c) => routeToAgent(c, manychatAdapter)); -// WhatsApp (Twilio): rutea el mensaje entrante al bot de clientes (Claude). El -// body se lee UNA vez; ack con TwiML vacío para que Twilio no reenvíe el cuerpo -// como mensaje. +// Throttle por IP (defensa en profundidad, ver src/rate-limit.ts) — corre +// ANTES de verificar firma/secret, así una ráfaga no le cuesta ni un HMAC al +// Worker. La capa principal es una Cloudflare Rate Limiting Rule a nivel de +// cuenta (fuera de este repo). +async function checkRateLimit(c: { req: { raw: Request }; env: Env }, bucket: string): Promise { + const db = new Db(c.env.DB); + return isRateLimited(db, bucket, clientIp(c.req.raw)); +} + +// Telegram firma cada POST con el secret_token puesto al registrar el webhook +// (setWebhook). Sin verificar esto, cualquiera que adivine la URL del Worker +// puede mandar un Update falso — incluso suplantar al dueño, ya que +// OWNER_TELEGRAM_CHAT_ID no es secreto. Fail-closed. +app.post("/webhooks/telegram", async (c) => { + if (await checkRateLimit(c, "webhooks/telegram")) return c.text("too many requests", 429); + if (!verifyTelegramSecret(c.req.header("x-telegram-bot-api-secret-token"), c.env)) { + return c.text("forbidden", 403); + } + return routeToAgent(c, telegramAdapter); +}); + +// ManyChat no firma sus External Requests: la protección es un shared secret +// que el miembro configura como header custom en su flow. Fail-closed. +app.post("/webhooks/manychat", async (c) => { + if (await checkRateLimit(c, "webhooks/manychat")) return c.text("too many requests", 429); + if (!verifyManychatSecret(c.req.header("x-manychat-secret"), c.env)) { + return c.text("forbidden", 403); + } + return routeToAgent(c, manychatAdapter); +}); + +// WhatsApp (Twilio): rutea el mensaje entrante al bot de clientes (Claude). +// Valida X-Twilio-Signature (fail-closed) antes de procesar — el body de +// formData() se lee del clon para no consumir el stream que necesita +// twilioAdapter.parseIncoming(). Ack con TwiML vacío para que Twilio no +// reenvíe el cuerpo como mensaje. app.post("/webhooks/twilio", async (c) => { + if (await checkRateLimit(c, "webhooks/twilio")) return c.text("too many requests", 429); + const form = await c.req.raw.clone().formData(); + const params: Record = {}; + for (const [k, v] of form.entries()) params[k] = String(v); + const valid = await verifyTwilioSignature( + c.req.url, + params, + c.req.header("x-twilio-signature"), + c.env.TWILIO_AUTH_TOKEN, + ); + if (!valid) return c.text("bad signature", 403); + let msg; try { msg = await twilioAdapter.parseIncoming(c.req.raw, c.env); @@ -205,6 +249,13 @@ app.route("/api", apiApp); // KB_REINDEX_TOKEN secret via the X-Reindex-Token header. Trigger after deploy: // curl -X POST https:///kb/reindex -H "X-Reindex-Token: " app.post("/kb/reindex", async (c) => { + // Fail-closed por token, pero sin límite de intentos: un throttle más + // estricto (5/min) que el de los webhooks frena la fuerza bruta sobre el + // token mismo. + const db = new Db(c.env.DB); + if (await isRateLimited(db, "kb/reindex", clientIp(c.req.raw), { max: 5 })) { + return c.json({ ok: false, error: "too many requests" }, 429); + } const provided = c.req.header("X-Reindex-Token") ?? ""; const expected = c.env.KB_REINDEX_TOKEN ?? ""; if (!expected || !tokensMatch(provided, expected)) { diff --git a/src/rate-limit.ts b/src/rate-limit.ts new file mode 100644 index 0000000..3f16528 --- /dev/null +++ b/src/rate-limit.ts @@ -0,0 +1,56 @@ +// Throttle simple por IP — defensa en profundidad para rutas expuestas sin +// sesión (webhooks, /kb/reindex) mientras se propaga la protección principal: +// una Cloudflare Rate Limiting Rule a nivel de cuenta (fuera de este repo, se +// configura en el dashboard de Cloudflare o en `wrangler.toml` con `[[rules]]` +// de tipo `ratelimit` — ver checklist de deploy). Esta capa cubre el caso en +// que esa regla no está configurada, y no depende de ningún binding nuevo: +// reusa el D1 (`DB`) que el bot ya tiene. +// +// Ventana fija (no sliding window): cada `bucket:ip` cuenta cuántos requests +// cayeron en el intervalo de windowMs actual; al cruzar a la siguiente +// ventana el contador se resetea solo. Suficiente para frenar ráfagas e +// intentos de fuerza bruta — no pretende ser exacto al request. +import { Db } from "./db/client"; + +const DEFAULT_WINDOW_MS = 60_000; +const DEFAULT_MAX = 20; + +export interface RateLimitOptions { + windowMs?: number; + max?: number; +} + +/** true si `ip` ya superó el tope de requests en la ventana actual para `bucket`. */ +export async function isRateLimited( + db: Db, + bucket: string, + ip: string, + opts: RateLimitOptions = {}, + now = Date.now(), +): Promise { + const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS; + const max = opts.max ?? DEFAULT_MAX; + const key = `${bucket}:${ip}`; + const windowStart = Math.floor(now / windowMs) * windowMs; + + // Upsert atómico: misma ventana → incrementa; ventana nueva → resetea a 1. + await db.run( + `INSERT INTO rate_limits (key, window_start, count) + VALUES (?, ?, 1) + ON CONFLICT(key) DO UPDATE SET + count = CASE WHEN rate_limits.window_start = excluded.window_start THEN rate_limits.count + 1 ELSE 1 END, + window_start = excluded.window_start`, + [key, windowStart], + ); + + const row = await db.first<{ count: number }>( + `SELECT count FROM rate_limits WHERE key = ?`, + [key], + ); + return (row?.count ?? 0) > max; +} + +/** IP del cliente tal como la ve Cloudflare — no falsificable por el request entrante. */ +export function clientIp(req: Request): string { + return req.headers.get("cf-connecting-ip") ?? "unknown"; +} diff --git a/test/budget.test.ts b/test/budget.test.ts index fe06f32..b8c844f 100644 --- a/test/budget.test.ts +++ b/test/budget.test.ts @@ -7,7 +7,9 @@ import { createTestMiniflare } from "./helpers/miniflareSetup"; import { Db } from "../src/db/client"; import { ConversationsRepo } from "../src/db/conversations"; import { MessagesRepo } from "../src/db/messages"; -import { monthIaCostUsd, monthStartMs, applyBudgetGuard } from "../src/budget"; +import { SettingsRepo, SETTING_KEYS } from "../src/db/settings"; +import { monthIaCostUsd, monthStartMs, applyBudgetGuard, enforceBudgetGuard } from "../src/budget"; +import type { Env } from "../src/env"; describe("applyBudgetGuard", () => { it("does nothing without a budget", () => { @@ -64,3 +66,42 @@ describe("monthIaCostUsd", () => { expect(await monthIaCostUsd(db)).toBe(0); }); }); + +describe("enforceBudgetGuard", () => { + let db: Db; + let settings: SettingsRepo; + // Sin ningún canal de aviso configurado, notifyOwner solo loguea y retorna + // (ver src/tools/handoffHuman.ts) — no hace falta mockear fetch/Resend. + const env = { DASHBOARD_BASE_URL: "https://test.workers.dev" } as unknown as Env; + + beforeEach(async () => { + const mf = await createTestMiniflare(); + db = new Db((await mf.getD1Database("DB")) as any); + settings = new SettingsRepo(db); + }); + + it("downgrades but does NOT pause below the 1.5x hard-stop", async () => { + const result = await enforceBudgetGuard(env, db, "smart", 6, 5); // 1.2x + expect(result).toEqual({ tier: "fast", downgraded: true, paused: false }); + expect(await settings.get(SETTING_KEYS.botPaused)).toBeNull(); + }); + + it("pauses the bot and flips bot_paused at/over 1.5x the budget", async () => { + const result = await enforceBudgetGuard(env, db, "smart", 7.5, 5); // exactly 1.5x + expect(result.paused).toBe(true); + expect(await settings.get(SETTING_KEYS.botPaused)).toBe("1"); + }); + + it("is idempotent — does not re-report paused once already paused", async () => { + await enforceBudgetGuard(env, db, "smart", 10, 5); + const second = await enforceBudgetGuard(env, db, "fast", 10, 5); + expect(second.paused).toBe(false); // ya estaba pausado, no hay nada nuevo que hacer + expect(await settings.get(SETTING_KEYS.botPaused)).toBe("1"); + }); + + it("never pauses without a configured budget", async () => { + const result = await enforceBudgetGuard(env, db, "smart", 9999, undefined); + expect(result.paused).toBe(false); + expect(await settings.get(SETTING_KEYS.botPaused)).toBeNull(); + }); +}); diff --git a/test/channels/manychat.test.ts b/test/channels/manychat.test.ts index 1f270b1..5b57948 100644 --- a/test/channels/manychat.test.ts +++ b/test/channels/manychat.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { manychatAdapter } from "../../src/channels/manychat"; +import { manychatAdapter, verifyManychatSecret } from "../../src/channels/manychat"; import type { Env } from "../../src/env"; // Payloads mirror a typical ManyChat/n8n flow: ManyChat posts the subscriber in @@ -149,3 +149,23 @@ describe("manychatAdapter.sendReply", () => { expect(body.data.content.type).toBe("whatsapp"); }); }); + +describe("verifyManychatSecret", () => { + const withSecret = { MANYCHAT_WEBHOOK_SECRET: "mc-shh-456" } as Env; + + it("fails closed when MANYCHAT_WEBHOOK_SECRET is not configured", () => { + expect(verifyManychatSecret("mc-shh-456", {} as Env)).toBe(false); + }); + + it("rejects a missing header", () => { + expect(verifyManychatSecret(undefined, withSecret)).toBe(false); + }); + + it("rejects a wrong header value", () => { + expect(verifyManychatSecret("wrong", withSecret)).toBe(false); + }); + + it("accepts the exact configured secret", () => { + expect(verifyManychatSecret("mc-shh-456", withSecret)).toBe(true); + }); +}); diff --git a/test/channels/telegram.test.ts b/test/channels/telegram.test.ts index ed919f8..0423b4b 100644 --- a/test/channels/telegram.test.ts +++ b/test/channels/telegram.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { telegramAdapter, resolveTelegramFileUrl } from "../../src/channels/telegram"; +import { telegramAdapter, resolveTelegramFileUrl, verifyTelegramSecret } from "../../src/channels/telegram"; import type { Env } from "../../src/env"; function makeReq(body: unknown): Request { @@ -120,3 +120,24 @@ describe("resolveTelegramFileUrl", () => { expect(url).toBeNull(); }); }); + +describe("verifyTelegramSecret", () => { + const withSecret = { TELEGRAM_WEBHOOK_SECRET: "shh-123" } as Env; + + it("fails closed when TELEGRAM_WEBHOOK_SECRET is not configured", () => { + expect(verifyTelegramSecret("shh-123", {} as Env)).toBe(false); + }); + + it("rejects a missing header", () => { + expect(verifyTelegramSecret(undefined, withSecret)).toBe(false); + expect(verifyTelegramSecret(null, withSecret)).toBe(false); + }); + + it("rejects a wrong header value", () => { + expect(verifyTelegramSecret("wrong", withSecret)).toBe(false); + }); + + it("accepts the exact configured secret", () => { + expect(verifyTelegramSecret("shh-123", withSecret)).toBe(true); + }); +}); diff --git a/test/channels/twilio.test.ts b/test/channels/twilio.test.ts index b70d5d4..eba8528 100644 --- a/test/channels/twilio.test.ts +++ b/test/channels/twilio.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { twilioAdapter } from "../../src/channels/twilio"; +import { createHmac } from "node:crypto"; +import { twilioAdapter, verifyTwilioSignature } from "../../src/channels/twilio"; describe("twilioAdapter.parseIncoming", () => { it("parses text WA", async () => { @@ -46,3 +47,52 @@ describe("twilioAdapter.parseIncoming", () => { expect(msg.audioUrl).toBe("https://media.twilio/voice.ogg"); }); }); + +/** + * Reference implementation of Twilio's signing algorithm (see + * https://www.twilio.com/docs/usage/security#validating-requests), computed + * independently via Node's `crypto` (HMAC-SHA1 + base64) rather than the Web + * Crypto path `verifyTwilioSignature` uses — a genuine cross-check that the + * production code implements the spec correctly, not a tautology. + */ +function referenceTwilioSignature(url: string, params: Record, authToken: string): string { + let data = url; + for (const key of Object.keys(params).sort()) data += key + params[key]; + return createHmac("sha1", authToken).update(data).digest("base64"); +} + +describe("verifyTwilioSignature", () => { + const AUTH_TOKEN = "test-auth-token-abc"; + const URL = "https://bot.test/webhooks/twilio"; + const PARAMS = { + From: "whatsapp:+5215512345", + To: "whatsapp:+5215587654", + Body: "hola", + NumMedia: "0", + }; + + it("accepts a correctly computed signature", async () => { + const sig = referenceTwilioSignature(URL, PARAMS, AUTH_TOKEN); + expect(await verifyTwilioSignature(URL, PARAMS, sig, AUTH_TOKEN)).toBe(true); + }); + + it("rejects a tampered param (signature no longer matches)", async () => { + const sig = referenceTwilioSignature(URL, PARAMS, AUTH_TOKEN); + const tampered = { ...PARAMS, Body: "algo distinto" }; + expect(await verifyTwilioSignature(URL, tampered, sig, AUTH_TOKEN)).toBe(false); + }); + + it("rejects a signature computed with the wrong auth token", async () => { + const sig = referenceTwilioSignature(URL, PARAMS, "otro-token"); + expect(await verifyTwilioSignature(URL, PARAMS, sig, AUTH_TOKEN)).toBe(false); + }); + + it("fails closed when the signature header is missing", async () => { + expect(await verifyTwilioSignature(URL, PARAMS, undefined, AUTH_TOKEN)).toBe(false); + }); + + it("fails closed when TWILIO_AUTH_TOKEN is not configured", async () => { + const sig = referenceTwilioSignature(URL, PARAMS, AUTH_TOKEN); + expect(await verifyTwilioSignature(URL, PARAMS, sig, undefined)).toBe(false); + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts index 8017397..d36164f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createTestMiniflare } from "./helpers/miniflareSetup"; // `src/index.ts` re-exports `SupportAgent` from `./agent`, which imports the // `agents` SDK. `agents` (via `partyserver`) imports the virtual @@ -10,16 +11,16 @@ vi.mock("agents", () => ({ Agent: class {} })); import worker from "../src/index"; -describe("Worker entry", () => { - const env = { - BOT_NAME: "Testi", - BUSINESS_NAME: "Test", - BOT_LANGUAGE: "es", - BOT_TIER: "pro", - BUFFER_SECONDS: "15", - DASHBOARD_BASE_URL: "https://test.workers.dev", - } as any; +const env = { + BOT_NAME: "Testi", + BUSINESS_NAME: "Test", + BOT_LANGUAGE: "es", + BOT_TIER: "pro", + BUFFER_SECONDS: "15", + DASHBOARD_BASE_URL: "https://test.workers.dev", +} as any; +describe("Worker entry", () => { it("returns 200 on /health", async () => { const res = await worker.fetch(new Request("https://test/health"), env, {} as any); expect(res.status).toBe(200); @@ -30,3 +31,155 @@ describe("Worker entry", () => { expect(res.status).toBe(404); }); }); + +// Los webhooks de Telegram/Manychat/Twilio deben rechazar (403) cualquier POST +// que no traiga la firma/secret correcta, ANTES de tocar el agente — así se +// evita que cualquiera que adivine la URL del Worker mande mensajes falsos. +// (El caso "firma válida" no se asserta como 200 aquí porque routeToAgent +// llama al Durable Object AGENT vía RPC, que este entorno de test no monta; +// alcanza con confirmar que la verificación deja de bloquear con 403.) +// Las rutas ahora también consultan D1 para el throttle por IP (rate-limit.ts), +// así que estos tests necesitan un binding DB real — viene de Miniflare, igual +// que test/learn/endpoint.test.ts. +describe("Webhook signature/secret guards (fail-closed)", () => { + let dbEnv: any; + + beforeEach(async () => { + const mf = await createTestMiniflare(); + const d1 = await mf.getD1Database("DB"); + dbEnv = { ...env, DB: d1 }; + }); + + it("POST /webhooks/telegram: 403 without TELEGRAM_WEBHOOK_SECRET configured", async () => { + const res = await worker.fetch( + new Request("https://test/webhooks/telegram", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Telegram-Bot-Api-Secret-Token": "whatever" }, + body: JSON.stringify({ update_id: 1 }), + }), + dbEnv, + {} as any, + ); + expect(res.status).toBe(403); + }); + + it("POST /webhooks/telegram: 403 with the wrong secret header", async () => { + const res = await worker.fetch( + new Request("https://test/webhooks/telegram", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Telegram-Bot-Api-Secret-Token": "wrong" }, + body: JSON.stringify({ update_id: 1 }), + }), + { ...dbEnv, TELEGRAM_WEBHOOK_SECRET: "correct-secret" }, + {} as any, + ); + expect(res.status).toBe(403); + }); + + it("POST /webhooks/telegram: NOT 403 with the correct secret header", async () => { + const res = await worker.fetch( + new Request("https://test/webhooks/telegram", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Telegram-Bot-Api-Secret-Token": "correct-secret" }, + body: JSON.stringify({ update_id: 1 }), + }), + { ...dbEnv, TELEGRAM_WEBHOOK_SECRET: "correct-secret" }, + {} as any, + ); + expect(res.status).not.toBe(403); + }); + + it("POST /webhooks/manychat: 403 without MANYCHAT_WEBHOOK_SECRET configured", async () => { + const res = await worker.fetch( + new Request("https://test/webhooks/manychat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "1" }), + }), + dbEnv, + {} as any, + ); + expect(res.status).toBe(403); + }); + + it("POST /webhooks/manychat: NOT 403 with the correct secret header", async () => { + const res = await worker.fetch( + new Request("https://test/webhooks/manychat", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Manychat-Secret": "mc-correct" }, + body: JSON.stringify({ id: "1" }), + }), + { ...dbEnv, MANYCHAT_WEBHOOK_SECRET: "mc-correct" }, + {} as any, + ); + expect(res.status).not.toBe(403); + }); + + it("POST /webhooks/twilio: 403 without a valid X-Twilio-Signature", async () => { + const body = new URLSearchParams({ From: "whatsapp:+1", To: "whatsapp:+2", Body: "hola", NumMedia: "0" }); + const res = await worker.fetch( + new Request("https://test/webhooks/twilio", { method: "POST", body }), + { ...dbEnv, TWILIO_AUTH_TOKEN: "tok" }, + {} as any, + ); + expect(res.status).toBe(403); + }); +}); + +// Throttle por IP: N+1º request en la misma ventana → 429, ANTES incluso de +// mirar la firma/token (así una ráfaga no le cuesta ni un HMAC al Worker). +describe("Rate limiting (defense in depth)", () => { + let dbEnv: any; + + beforeEach(async () => { + const mf = await createTestMiniflare(); + const d1 = await mf.getD1Database("DB"); + dbEnv = { ...env, DB: d1, TELEGRAM_WEBHOOK_SECRET: "s", KB_REINDEX_TOKEN: "kbtok" }; + }); + + function telegramReq() { + return new Request("https://test/webhooks/telegram", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Telegram-Bot-Api-Secret-Token": "wrong", // el throttle debe disparar ANTES del 403 por firma + "CF-Connecting-IP": "203.0.113.9", + }, + body: JSON.stringify({ update_id: 1 }), + }); + } + + it("429s after 20 requests/min from the same IP", async () => { + let last: Response | undefined; + for (let i = 0; i < 21; i++) { + last = await worker.fetch(telegramReq(), dbEnv, {} as any); + } + expect(last?.status).toBe(429); + }); + + it("a different IP is not affected by another IP's flood", async () => { + for (let i = 0; i < 21; i++) await worker.fetch(telegramReq(), dbEnv, {} as any); + const freshIp = new Request("https://test/webhooks/telegram", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Telegram-Bot-Api-Secret-Token": "wrong", + "CF-Connecting-IP": "198.51.100.7", + }, + body: JSON.stringify({ update_id: 1 }), + }); + const res = await worker.fetch(freshIp, dbEnv, {} as any); + expect(res.status).toBe(403); // rechazado por firma, no por rate limit + }); + + it("/kb/reindex throttles at a stricter 5/min", async () => { + const req = () => + new Request("https://test/kb/reindex", { + method: "POST", + headers: { "X-Reindex-Token": "wrong", "CF-Connecting-IP": "203.0.113.50" }, + }); + let last: Response | undefined; + for (let i = 0; i < 6; i++) last = await worker.fetch(req(), dbEnv, {} as any); + expect(last?.status).toBe(429); + }); +}); diff --git a/test/rate-limit.test.ts b/test/rate-limit.test.ts new file mode 100644 index 0000000..45d5b49 --- /dev/null +++ b/test/rate-limit.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestMiniflare } from "./helpers/miniflareSetup"; +import { Db } from "../src/db/client"; +import { isRateLimited, clientIp } from "../src/rate-limit"; + +let db: Db; + +beforeEach(async () => { + const mf = await createTestMiniflare(); + db = new Db((await mf.getD1Database("DB")) as any); +}); + +describe("isRateLimited", () => { + it("allows up to `max` requests in the same window, blocks the next", async () => { + const now = 1_000_000; + for (let i = 0; i < 3; i++) { + expect(await isRateLimited(db, "test", "1.2.3.4", { max: 3, windowMs: 60_000 }, now)).toBe(false); + } + expect(await isRateLimited(db, "test", "1.2.3.4", { max: 3, windowMs: 60_000 }, now)).toBe(true); + }); + + it("resets the counter once the window rolls over", async () => { + const windowMs = 60_000; + const windowStart = 120_000; // aligned to a window boundary + for (let i = 0; i < 3; i++) { + expect(await isRateLimited(db, "test", "1.2.3.4", { max: 3, windowMs }, windowStart)).toBe(false); + } + expect(await isRateLimited(db, "test", "1.2.3.4", { max: 3, windowMs }, windowStart)).toBe(true); + // Next window: counter must have reset. + expect(await isRateLimited(db, "test", "1.2.3.4", { max: 3, windowMs }, windowStart + windowMs)).toBe(false); + }); + + it("tracks each IP independently", async () => { + const now = 5_000; + for (let i = 0; i < 2; i++) await isRateLimited(db, "test", "1.1.1.1", { max: 2 }, now); + expect(await isRateLimited(db, "test", "1.1.1.1", { max: 2 }, now)).toBe(true); + expect(await isRateLimited(db, "test", "2.2.2.2", { max: 2 }, now)).toBe(false); + }); + + it("tracks each bucket independently for the same IP", async () => { + const now = 5_000; + for (let i = 0; i < 2; i++) await isRateLimited(db, "bucket-a", "1.1.1.1", { max: 2 }, now); + expect(await isRateLimited(db, "bucket-a", "1.1.1.1", { max: 2 }, now)).toBe(true); + expect(await isRateLimited(db, "bucket-b", "1.1.1.1", { max: 2 }, now)).toBe(false); + }); +}); + +describe("clientIp", () => { + it("reads CF-Connecting-IP", () => { + const req = new Request("https://x", { headers: { "CF-Connecting-IP": "9.9.9.9" } }); + expect(clientIp(req)).toBe("9.9.9.9"); + }); + + it("falls back to 'unknown' when the header is absent", () => { + expect(clientIp(new Request("https://x"))).toBe("unknown"); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index 4f91463..d9b73d4 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -44,8 +44,18 @@ DASHBOARD_BASE_URL = "https://horizontes-bot-{{BOT_SLUG}}.workers.dev" # KB_REINDEX_TOKEN required — guards POST /kb/reindex (header: X-Reindex-Token) # set with: wrangler secret put KB_REINDEX_TOKEN # TELEGRAM_BOT_TOKEN per-channel — Telegram bot +# TELEGRAM_WEBHOOK_SECRET required if using Telegram — secret_token passed to setWebhook; +# without it /webhooks/telegram rejects everything (fail-closed). +# set with: wrangler secret put TELEGRAM_WEBHOOK_SECRET +# then register it: curl "https://api.telegram.org/bot/setWebhook" \ +# -d url=https:///webhooks/telegram -d secret_token= # MANYCHAT_API_KEY per-channel — ManyChat (Pro) +# MANYCHAT_WEBHOOK_SECRET required if using ManyChat — shared secret sent by ManyChat as a +# custom header (X-Manychat-Secret) on its External Request; without +# it /webhooks/manychat rejects everything (fail-closed). +# set with: wrangler secret put MANYCHAT_WEBHOOK_SECRET # TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_WA_FROM per-channel — Twilio WhatsApp (Pro) +# (TWILIO_AUTH_TOKEN also validates X-Twilio-Signature on /webhooks/twilio) # CALCOM_API_KEY optional — scheduleAppointment (Pro) # GOOGLE_SERVICE_ACCOUNT_JSON optional — Sheets export (Pro) # RESEND_API_KEY optional — handoff email @@ -60,5 +70,13 @@ DASHBOARD_BASE_URL = "https://horizontes-bot-{{BOT_SLUG}}.workers.dev" [triggers] crons = ["0 3 * * *"] # daily 3am UTC: purge old messages +# Rate limiting: el Worker ya trae un throttle por IP de baja fricción +# (src/rate-limit.ts, D1-backed) en los webhooks y /kb/reindex. Para producción +# real (sobre todo si vas a revender/manejar varios clientes), agrega además una +# Cloudflare Rate Limiting Rule a nivel de cuenta — es la capa principal, corre +# antes de que el request le cueste nada al Worker: +# dashboard → tu dominio → Security → WAF → Rate limiting rules +# (o `wrangler` con un [[rules]] tipo ratelimit una vez el binding sea GA) + # DASHBOARD_PASSWORD (secret) — HTTP Basic Auth password for the admin dashboard; username is always "admin". # Set it with: wrangler secret put DASHBOARD_PASSWORD