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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user