Seguridad: rate limiting, códigos de un solo uso, rotación de FERNET_KEY

- utils/rate_limiter.py: ventana deslizante 20 req/60s por chat_id,
  aplicado en todos los handlers de telegram_bot.py
- database.py: tabla invite_codes (un solo uso por código), funciones
  crear_invite_code() y verificar_y_consumir_invite_code()
- onboarding.py: verifica primero en DB (un solo uso), fallback al
  INVITE_CODE del .env (ilimitado, para uso interno)
- scripts/generar_codigo.py: genera 1 o N códigos de invitación
- scripts/rotar_fernet_key.py: re-cifra todos los tenants y el .env
  con nueva FERNET_KEY, backup automático de .env antes de escribir

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-05-25 22:33:50 -03:00
parent 6487a7857c
commit 7865008618
8 changed files with 457 additions and 4 deletions

View File

@@ -29,6 +29,7 @@ from telegram.ext import (
import audit
import vault as vault_mod
from config import Settings
from utils.rate_limiter import RateLimiter
from csj_client import (
CSJAuthError,
CSJClient,
@@ -218,11 +219,20 @@ class PedritoBot:
self._onboarding = onboarding
self._app: Application | None = None
self._on_notif_request: object = None # callable(chat_id) → coroutine
self._rl = RateLimiter(max_requests=20, window_seconds=60)
def set_notif_callback(self, callback: object) -> None:
"""Registra el callable que dispara una revisión manual. Recibe chat_id como arg."""
self._on_notif_request = callback
async def _check_rl(self, chat_id: int) -> bool:
"""Retorna False y loggea si el chat_id excedió el rate limit."""
if not self._rl.is_allowed(chat_id):
await audit.log("rate_limit_excedido", chat_id=chat_id)
logger.warning("rate_limit_excedido", chat_id=chat_id)
return False
return True
def _snapshot_for(self, chat_id: int) -> Snapshot:
path = self._settings.data_dir / f"snapshot_{chat_id}.json"
return Snapshot(path)
@@ -259,6 +269,8 @@ class PedritoBot:
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "start"})
tenant = await get_tenant(chat_id)
@@ -281,6 +293,8 @@ class PedritoBot:
async def cmd_ayuda(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "ayuda"})
if not await self._get_tenant_activo(chat_id):
await self._rechazar_no_registrado(update)
@@ -302,6 +316,8 @@ class PedritoBot:
async def cmd_estado(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "estado"})
tenant = await self._get_tenant_activo(chat_id)
if not tenant:
@@ -332,6 +348,8 @@ class PedritoBot:
async def cmd_notif(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "notif"})
if not await self._get_tenant_activo(chat_id):
await self._rechazar_no_registrado(update)
@@ -350,6 +368,8 @@ class PedritoBot:
async def cmd_salir(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "salir"})
tenant = await get_tenant(chat_id)
if not tenant:
@@ -389,6 +409,7 @@ class PedritoBot:
if snapshot_path.exists():
snapshot_path.unlink()
self._rl.reset(target_id)
logger.info("resetear_tenant", target_chat_id=target_id, por=caller_id)
await audit.log("tenant_reseteado", chat_id=target_id, detalle={"por": caller_id})
@@ -398,6 +419,8 @@ class PedritoBot:
async def cmd_exp(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "exp"})
tenant = await self._get_tenant_activo(chat_id)
if not tenant:
@@ -470,6 +493,8 @@ class PedritoBot:
async def cmd_pdf(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
await audit.log("comando_recibido", chat_id=chat_id, detalle={"comando": "pdf"})
tenant = await self._get_tenant_activo(chat_id)
if not tenant:
@@ -593,6 +618,8 @@ class PedritoBot:
async def handle_mensaje(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Procesa texto libre — redirige al onboarding si el usuario está en ese flujo."""
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
await audit.log("mensaje_recibido", chat_id=chat_id)
if self._onboarding.is_in_onboarding(chat_id):
await self._onboarding.handle_mensaje(update, context)
@@ -600,6 +627,8 @@ class PedritoBot:
async def handle_texto_libre(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Responde a texto que no es comando ni está en un flujo de onboarding."""
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
if self._onboarding.is_in_onboarding(chat_id):
return # el onboarding lo maneja en handle_mensaje, no responder acá
@@ -618,6 +647,8 @@ class PedritoBot:
async def handle_onb_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Callbacks de onboarding (botones onb:...)."""
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
await audit.log("callback_recibido", chat_id=chat_id, detalle={"tipo": "onb"})
await self._onboarding.handle_callback(update, context)
@@ -625,6 +656,8 @@ class PedritoBot:
"""Handler del botón 'Ver PDF' en notificaciones proactivas."""
query = update.callback_query
chat_id = update.effective_chat.id # type: ignore[union-attr]
if not await self._check_rl(chat_id):
return
await audit.log("callback_recibido", chat_id=chat_id, detalle={"tipo": "pdf"})
if not await self._get_tenant_activo(chat_id): # type: ignore[arg-type]