security: hardening contra 7 vectores de ataque identificados

Análisis ofensivo → defensas implementadas:

1. /health/history sin auth (CRÍTICO)
   - Requiere Bearer token (HEALTH_AUTH_TOKEN en .env)
   - chat_ids enmascarados como hash SHA256[:8] opaco ("usr_a3b4c5d6")
   - Accesos denegados loggeados con IP + User-Agent

2. /health revela inteligencia operacional (MEDIO)
   - Sin auth: solo {"status", "timestamp"} — sin conteo de tenants ni timing
   - Con auth: respuesta completa con métricas internas
   - Misma URL, auth desbloquea detalle

3. Variables sensibles en proceso env (CRÍTICO)
   - Después de cargar Settings(), se limpian FERNET_KEY, TELEGRAM_BOT_TOKEN,
     PJ_USUARIO_ENC, PJ_PASSWORD_ENC, WEBHOOK_SECRET, HEALTH_AUTH_TOKEN, INVITE_CODE
   - Protege contra /proc/<pid>/environ y herencia por subprocesos

4. Race condition TOCTOU en invite codes (MEDIO)
   - SELECT+UPDATE reemplazado por UPDATE WHERE usado=0
   - rowcount=0 → False; imposible que dos requests simultáneos consuman el mismo código

5. Timing oracle en invite codes (BAJO-MEDIO)
   - Delay mínimo de 800ms en _paso_codigo() independientemente del resultado
   - secrets.compare_digest() para comparación del código del .env

6. Information disclosure en respuestas a no-registrados (BAJO)
   - _rechazar_no_registrado: "Para usar este bot escribí /start." (sin revelar comandos)
   - handle_texto_libre: silencio total para usuarios sin registro (no confirmar que el bot existe)

7. Nginx rate limiting (DEPLOY.md)
   - limit_req_zone pedrito_webhook: 30r/s, burst=50
   - limit_req_zone pedrito_health: 10r/m, burst=5
   - Headers de seguridad: X-Content-Type-Options, X-Frame-Options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-05-26 08:06:36 -03:00
parent 6aa8b8fa62
commit fa72ced5a6
7 changed files with 200 additions and 58 deletions

View File

@@ -258,8 +258,10 @@ class PedritoBot:
return None
async def _rechazar_no_registrado(self, update: Update) -> None:
# Mensaje mínimo: no revela qué hace el bot ni su arquitectura interna.
# Solo dice que hay un comando /start, lo cual es estándar en todos los bots de Telegram.
await update.effective_message.reply_text( # type: ignore[union-attr]
"Primero tenés que registrarte\\. Escribí /start\\.",
"Para usar este bot escribí /start\\.",
parse_mode=ParseMode.MARKDOWN_V2,
)
@@ -635,10 +637,9 @@ class PedritoBot:
tenant = await get_tenant(chat_id)
if tenant and tenant.get("estado") == "activo":
texto = _escape_md(_texto_tono("texto_no_reconocido", tenant))
else:
texto = _escape_md("Solo respondo comandos específicos. Escribí /ayuda para ver la lista.")
await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
# Usuarios sin registro: silencio total.
# No confirmar que el bot existe ni qué hace — quien lo debe usar ya sabe cómo.
# ──────────────────────────────────────────────────────────────────────────
# Handlers de callbacks