Skip to content

security: sign Telegram/ManyChat/Twilio webhooks, throttle by IP, hard-stop the budget guard - #2

Closed
Consultoriaintegralrf wants to merge 1 commit into
santmun:mainfrom
Consultoriaintegralrf:security/webhook-auth-rate-limit-budget-guard
Closed

security: sign Telegram/ManyChat/Twilio webhooks, throttle by IP, hard-stop the budget guard#2
Consultoriaintegralrf wants to merge 1 commit into
santmun:mainfrom
Consultoriaintegralrf:security/webhook-auth-rate-limit-budget-guard

Conversation

@Consultoriaintegralrf

@Consultoriaintegralrf Consultoriaintegralrf commented Jul 29, 2026

Copy link
Copy Markdown

Qué cambia

Cierra tres huecos de seguridad reales encontrados en una auditoría del código: los webhooks de Telegram, Twilio y ManyChat no verificaban el origen del request (a diferencia de Meta/WhatsApp, que sí firman); no había rate limiting en ningún lado; y el guard de presupuesto mensual de IA nunca frenaba el gasto, solo degradaba el modelo.

  • Telegram: valida X-Telegram-Bot-Api-Secret-Token contra un nuevo secret TELEGRAM_WEBHOOK_SECRET (se configura al registrar el webhook con secret_token).
  • ManyChat: valida un nuevo header X-Manychat-Secret contra MANYCHAT_WEBHOOK_SECRET (ManyChat no firma nativamente, así que esto es un shared secret que se configura como header custom en el flow).
  • Twilio: valida X-Twilio-Signature (HMAC-SHA1 oficial de Twilio) usando el TWILIO_AUTH_TOKEN que ya existía — sin secret nuevo.
  • Los tres son fail-closed, replicando el patrón que ya usa verifyMetaSignature en src/channels/meta.ts.
  • Throttle por IP nuevo (src/rate-limit.ts, sobre el D1 existente, sin binding nuevo) en los tres webhooks y en /kb/reindex, antes de verificar firma/token.
  • Budget guard (src/budget.ts): además de degradar a modelo barato, al llegar a 1.5x el presupuesto mensual pausa el bot entero (mismo switch bot_paused del panel) y notifica al dueño por su canal de handoff.

Por qué

Sin verificación de origen, cualquiera que encontrara la URL del Worker podía mandar un Update/mensaje falso a Telegram/Twilio/ManyChat — incluso suplantar al dueño en Telegram, ya que OWNER_TELEGRAM_CHAT_ID no es secreto. Sin rate limiting, esto además permitía abuso de costo/DoS. El budget guard solo degradando el modelo no evita que una ráfaga rebase el presupuesto de todos modos.

Cómo lo probaste

  • pnpm test pasa (469/469, 32 tests nuevos)
  • pnpm typecheck limpio
  • (no aplica) probado contra un bot real — son cambios de verificación de webhook, cubiertos por tests de router + unitarios

Checklist

  • No toqué la carpeta member/
  • Es un solo tema (seguridad de webhooks + rate limiting + budget guard, planeados y verificados juntos)
  • Revisé el diff yo mismo antes de abrir el PR
  • No hay secrets ni API keys en el código

Nota para quien revise — breaking, intencional

Los bots ya desplegados con Telegram o ManyChat dejarán de responder por esos canales hasta que se configure TELEGRAM_WEBHOOK_SECRET / MANYCHAT_WEBHOOK_SECRET (documentado en los comentarios de wrangler.toml). Twilio no requiere cambios de config. También hace falta pnpm db:apply:remote para la tabla nueva rate_limits.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added IP-based rate limiting for webhooks and knowledge-base reindex requests.
    • Added fail-closed webhook verification for Telegram, ManyChat, and Twilio.
    • Added automatic AI budget tier downgrading and bot pausing when spending exceeds the hard-stop threshold.
  • Documentation

    • Expanded configuration guidance for webhook secrets, signatures, and rate limiting.
  • Tests

    • Added coverage for budget enforcement, webhook security, and rate-limiting behavior.

…d-stop the budget guard

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 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds monthly AI budget hard stops, fail-closed webhook authentication for Telegram, ManyChat, and Twilio, and D1-backed IP rate limiting for webhooks and knowledge-base reindex requests.

Changes

Monthly budget enforcement

Layer / File(s) Summary
Budget guard hard-stop behavior
src/budget.ts
Budget enforcement downgrades tiers at the configured limit, pauses the bot at 1.5× the budget, persists the pause state, and notifies the owner once.
Agent integration and enforcement tests
src/agent.ts, test/budget.test.ts
SupportAgent applies the enforcement result to model selection and logs downgrade or hard-stop outcomes; tests cover thresholds, persistence, idempotency, and missing budgets.

Webhook authentication and rate limiting

Layer / File(s) Summary
Provider credential verification
src/env.ts, src/channels/manychat.ts, src/channels/telegram.ts, src/channels/twilio.ts, src/index.ts
Adds fail-closed Telegram and ManyChat secret checks and Twilio HMAC-SHA1 signature verification.
D1 rate-limit storage
src/db/schema.sql, src/rate-limit.ts
Adds fixed-window counters keyed by bucket and client IP, plus Cloudflare client-IP extraction.
Guarded webhook and reindex routes
src/index.ts
Applies rate limits before credential checks, validates Twilio form signatures, and limits reindex requests to five per window.
Authentication and throttling validation
test/channels/*, test/index.test.ts, test/rate-limit.test.ts, wrangler.toml
Adds unit and integration coverage for credentials, signatures, rate-limit windows, route thresholds, and IP isolation; documents the configured secrets and throttling behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WebhookClient
  participant Worker
  participant D1
  participant SecretVerifier
  participant AgentRoute
  WebhookClient->>Worker: POST webhook request
  Worker->>D1: Check bucket and client IP
  D1-->>Worker: Allow or HTTP 429
  Worker->>SecretVerifier: Validate provider credential
  SecretVerifier-->>Worker: Valid or HTTP 403
  Worker->>AgentRoute: Forward accepted webhook
Loading

Suggested reviewers: santmun

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: webhook verification, IP throttling, and the budget hard-stop.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/agent.ts`:
- Around line 284-289: Update the guard.paused branch in the agent request flow
to return immediately after logging the monthly budget hard-stop, preventing
execution from reaching streamText() or sendReply(). Preserve the existing tier
assignment and behavior when the guard is not paused.

In `@src/budget.ts`:
- Around line 94-106: Update the botPaused handling in the budget guard to
atomically claim the pause state using a conditional insert/update operation
that indicates whether this invocation changed SETTING_KEYS.botPaused. Remove
the separate settings.get()/settings.set() sequence, and call notifyOwner() only
when the current invocation wins the atomic claim; preserve the existing guard
return for already-paused cases.

In `@src/rate-limit.ts`:
- Around line 37-49: Update the rate-limit persistence flow around the
INSERT/SELECT operations to remove expired rows using a bounded, preferably
scheduled or batched cleanup rather than retaining reset keys indefinitely. Add
an index on rate_limits.window_start and ensure cleanup targets only expired
windows while preserving active rate-limit rows and existing counting behavior.

In `@wrangler.toml`:
- Around line 73-75: Update the rate-limiting documentation in wrangler.toml to
explicitly require running pnpm db:apply:remote for existing deployments before
using guarded routes, ensuring the rate_limits D1 migration is applied before
requests reach authentication.
- Around line 76-79: Update the rate-limit guidance comments in wrangler.toml to
reference the Workers rate-limit API binding as [[ratelimits]] instead of
[[rules]]. Clarify that account-level WAF rate-limiting rules require an
Enterprise plan, or direct non-Enterprise users to zone-level rules.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 02b708d7-182e-4662-9db4-97c16115a768

📥 Commits

Reviewing files that changed from the base of the PR and between ffe32d8 and 1090527.

📒 Files selected for processing (16)
  • src/agent.ts
  • src/budget.ts
  • src/channels/manychat.ts
  • src/channels/telegram.ts
  • src/channels/twilio.ts
  • src/db/schema.sql
  • src/env.ts
  • src/index.ts
  • src/rate-limit.ts
  • test/budget.test.ts
  • test/channels/manychat.test.ts
  • test/channels/telegram.test.ts
  • test/channels/twilio.test.ts
  • test/index.test.ts
  • test/rate-limit.test.ts
  • wrangler.toml

Comment thread src/agent.ts
Comment on lines +284 to 289
if (guard.paused) {
console.error(
`[SupportAgent] monthly budget hard-stop ($${spent.toFixed(2)} >= 1.5x $${cfg.monthlyBudgetUsd}) — bot paused, owner notified`,
);
}
tier = guard.tier;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stop processing after a hard stop.

When guard.paused is true, this only logs and then proceeds to streamText() and sendReply(). Return immediately after logging so the triggering request does not generate another paid response after the budget pause.

Proposed fix
       if (guard.paused) {
         console.error(
           `[SupportAgent] monthly budget hard-stop ($${spent.toFixed(2)} >= 1.5x $${cfg.monthlyBudgetUsd}) — bot paused, owner notified`,
         );
+        return;
       }
       tier = guard.tier;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (guard.paused) {
console.error(
`[SupportAgent] monthly budget hard-stop ($${spent.toFixed(2)} >= 1.5x $${cfg.monthlyBudgetUsd}) — bot paused, owner notified`,
);
}
tier = guard.tier;
if (guard.paused) {
console.error(
`[SupportAgent] monthly budget hard-stop ($${spent.toFixed(2)} >= 1.5x $${cfg.monthlyBudgetUsd}) — bot paused, owner notified`,
);
return;
}
tier = guard.tier;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent.ts` around lines 284 - 289, Update the guard.paused branch in the
agent request flow to return immediately after logging the monthly budget
hard-stop, preventing execution from reaching streamText() or sendReply().
Preserve the existing tier assignment and behavior when the guard is not paused.

Comment thread src/budget.ts
Comment on lines +94 to +106
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",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the global pause claim atomic.

Two concurrent agents can both read an unpaused setting before either upsert completes, so both call notifyOwner(). Replace the separate get()/set() with a conditional insert/update that reports whether this invocation actually changed bot_paused, and notify only the winner.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/budget.ts` around lines 94 - 106, Update the botPaused handling in the
budget guard to atomically claim the pause state using a conditional
insert/update operation that indicates whether this invocation changed
SETTING_KEYS.botPaused. Remove the separate settings.get()/settings.set()
sequence, and call notifyOwner() only when the current invocation wins the
atomic claim; preserve the existing guard return for already-paused cases.

Comment thread src/rate-limit.ts
Comment on lines +37 to +49
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],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Add bounded retention for expired rate-limit keys.

Rows are reset but never deleted. Requests from rotating IPs create permanent bucket:ip records, allowing unbounded D1 growth and eventual failures for all guarded routes. Add expiry cleanup (preferably scheduled/batched) and index window_start for that cleanup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rate-limit.ts` around lines 37 - 49, Update the rate-limit persistence
flow around the INSERT/SELECT operations to remove expired rows using a bounded,
preferably scheduled or batched cleanup rather than retaining reset keys
indefinitely. Add an index on rate_limits.window_start and ensure cleanup
targets only expired windows while preserving active rate-limit rows and
existing counting behavior.

Comment thread wrangler.toml
Comment on lines +73 to +75
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Document the required D1 migration.

Existing deployments will query rate_limits immediately; without pnpm db:apply:remote, guarded routes fail before authentication.

Proposed fix
 # 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
+# Antes de desplegar esta versión sobre una D1 existente, ejecuta:
+#   pnpm db:apply:remote
+# Esto crea la tabla `rate_limits` requerida por esos guards.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# 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
# 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
# Antes de desplegar esta versión sobre una D1 existente, ejecuta:
# pnpm db:apply:remote
# Esto crea la tabla `rate_limits` requerida por esos guards.
# real (sobre todo si vas a revender/manejar varios clientes), agrega además una
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wrangler.toml` around lines 73 - 75, Update the rate-limiting documentation
in wrangler.toml to explicitly require running pnpm db:apply:remote for existing
deployments before using guarded routes, ensuring the rate_limits D1 migration
is applied before requests reach authentication.

Comment thread wrangler.toml
Comment on lines +76 to +79
# 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the rate-limit guidance in wrangler.toml.

[[rules]] is for module bundler rules, not bindings. For the Workers rate-limit API, the required Wrangler binding is [[ratelimits]]; also qualify account-level WAF rate-limiting rules as enterprise-only, or direct users to zone-level rules.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wrangler.toml` around lines 76 - 79, Update the rate-limit guidance comments
in wrangler.toml to reference the Workers rate-limit API binding as
[[ratelimits]] instead of [[rules]]. Clarify that account-level WAF
rate-limiting rules require an Enterprise plan, or direct non-Enterprise users
to zone-level rules.

@Consultoriaintegralrf Consultoriaintegralrf closed this by deleting the head repository Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant