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:
@@ -16,6 +16,9 @@ Estados:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
@@ -201,13 +204,28 @@ class OnboardingFlow:
|
||||
|
||||
async def _paso_codigo(self, chat_id: int, codigo: str, update: Update) -> None:
|
||||
codigo = codigo.strip()
|
||||
# Registrar tiempo de inicio para garantizar respuesta de duración constante.
|
||||
# Objetivo: ocultar la diferencia de tiempo entre "código no existe en DB"
|
||||
# (~0.001ms) y "código existe pero ya fue usado" (~3-5ms de DB lookup).
|
||||
t0 = time.monotonic()
|
||||
|
||||
# Primero: verificar contra la tabla de códigos de un solo uso (DB)
|
||||
valido = await verificar_y_consumir_invite_code(codigo, chat_id)
|
||||
|
||||
# Fallback: código global del .env (ilimitado, para uso interno/pruebas)
|
||||
# secrets.compare_digest previene timing attacks sobre el valor del .env
|
||||
if not valido and self._settings.invite_code:
|
||||
valido = (codigo == self._settings.invite_code)
|
||||
valido = secrets.compare_digest(
|
||||
codigo.encode("utf-8"),
|
||||
self._settings.invite_code.encode("utf-8"),
|
||||
)
|
||||
|
||||
# Garantizar un tiempo mínimo de 800ms independientemente del resultado.
|
||||
# Esto hace que medir tiempos de respuesta no revele información sobre los códigos.
|
||||
elapsed = time.monotonic() - t0
|
||||
_MIN_RESP_SECS = 0.8
|
||||
if elapsed < _MIN_RESP_SECS:
|
||||
await asyncio.sleep(_MIN_RESP_SECS - elapsed)
|
||||
|
||||
if valido:
|
||||
await audit.log("onboarding_codigo_correcto", chat_id=chat_id)
|
||||
|
||||
Reference in New Issue
Block a user