-
Notifications
You must be signed in to change notification settings - Fork 26
security: sign Telegram/ManyChat/Twilio webhooks, throttle by IP, hard-stop the budget guard #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<BudgetGuardResult> { | ||
| 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", | ||
| }); | ||
|
Comment on lines
+94
to
+106
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤖 Prompt for AI Agents |
||
| return { ...guard, paused: true }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<boolean> { | ||
| 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], | ||
| ); | ||
|
Comment on lines
+37
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤖 Prompt for AI Agents |
||
| 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"; | ||
| } | ||
There was a problem hiding this comment.
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.pausedis true, this only logs and then proceeds tostreamText()andsendReply(). 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
🤖 Prompt for AI Agents