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>
129 lines
4.1 KiB
Python
129 lines
4.1 KiB
Python
"""
|
|
config.py — Carga y valida variables de entorno.
|
|
|
|
Todas las partes de la app importan Settings desde acá.
|
|
Las credenciales del PJ se descifran aquí y se exponen como propiedades.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from functools import cached_property
|
|
from pathlib import Path
|
|
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
from pydantic import field_validator, model_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
case_sensitive=False,
|
|
extra="ignore",
|
|
)
|
|
|
|
# Cifrado
|
|
fernet_key: str
|
|
|
|
# Credenciales PJ cifradas (valores Fernet base64)
|
|
pj_usuario_enc: str
|
|
pj_password_enc: str
|
|
|
|
# Telegram
|
|
telegram_bot_token: str
|
|
telegram_chat_id: int # chat ID principal (recibe notificaciones automáticas)
|
|
telegram_extra_ids: str = "" # IDs adicionales separados por coma, ej: "111,222"
|
|
|
|
@property
|
|
def telegram_authorized_ids(self) -> set[int]:
|
|
"""Todos los chat IDs habilitados para usar el bot."""
|
|
ids = {self.telegram_chat_id}
|
|
for raw in self.telegram_extra_ids.split(","):
|
|
raw = raw.strip()
|
|
if raw:
|
|
ids.add(int(raw))
|
|
return ids
|
|
|
|
# Configuración del polling
|
|
poll_interval_minutes: int = 60
|
|
poll_hora_inicio: int = 7
|
|
poll_hora_fin: int = 18
|
|
pj_rol_id: int = 16
|
|
|
|
# Directorios
|
|
data_dir: Path = Path("./data")
|
|
db_path: Path = Path("./data/pasante.db")
|
|
|
|
# Código de invitación para registro (vacío = registro abierto)
|
|
invite_code: str = ""
|
|
|
|
# Token para los endpoints HTTP protegidos (/health detallado, /health/history)
|
|
# Sin auth configurada los endpoints devuelven respuesta mínima (status only)
|
|
health_auth_token: str = ""
|
|
|
|
# Webhook (vacío = long-polling)
|
|
webhook_url: str = "" # ej: https://bot.midominio.com
|
|
webhook_secret: str = "" # token secreto para X-Telegram-Bot-Api-Secret-Token
|
|
|
|
# Health check HTTP
|
|
health_port: int = 8080
|
|
health_alert_hours: int = 2 # alerta al admin si el poller no actualizó en N horas
|
|
|
|
# Logging
|
|
log_level: str = "INFO"
|
|
|
|
# URLs del PJ (no configurables, solo para tests)
|
|
pj_base_url: str = "https://apps.csj.gov.py"
|
|
|
|
@field_validator("poll_hora_inicio", "poll_hora_fin")
|
|
@classmethod
|
|
def validar_hora(cls, v: int) -> int:
|
|
if not (0 <= v <= 23):
|
|
raise ValueError(f"Hora debe ser entre 0 y 23, recibido: {v}")
|
|
return v
|
|
|
|
@model_validator(mode="after")
|
|
def validar_horario(self) -> Settings:
|
|
if self.poll_hora_inicio >= self.poll_hora_fin:
|
|
raise ValueError(
|
|
f"poll_hora_inicio ({self.poll_hora_inicio}) "
|
|
f"debe ser menor que poll_hora_fin ({self.poll_hora_fin})"
|
|
)
|
|
return self
|
|
|
|
@cached_property
|
|
def _fernet(self) -> Fernet:
|
|
try:
|
|
return Fernet(self.fernet_key.encode())
|
|
except Exception as e:
|
|
raise ValueError(f"FERNET_KEY inválida: {e}") from e
|
|
|
|
def descifrar_usuario(self) -> str:
|
|
"""Descifra y retorna la cédula del abogado. Usar solo al momento de login."""
|
|
try:
|
|
return self._fernet.decrypt(self.pj_usuario_enc.encode()).decode()
|
|
except InvalidToken as e:
|
|
raise ValueError("PJ_USUARIO_ENC no se pudo descifrar. ¿Usaste la misma FERNET_KEY?") from e
|
|
|
|
def descifrar_password(self) -> str:
|
|
"""Descifra y retorna el password del PJ. Usar solo al momento de login."""
|
|
try:
|
|
return self._fernet.decrypt(self.pj_password_enc.encode()).decode()
|
|
except InvalidToken as e:
|
|
raise ValueError("PJ_PASSWORD_ENC no se pudo descifrar. ¿Usaste la misma FERNET_KEY?") from e
|
|
|
|
def asegurar_data_dir(self) -> None:
|
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
# Singleton — importar desde cualquier lado
|
|
_settings: Settings | None = None
|
|
|
|
|
|
def get_settings() -> Settings:
|
|
global _settings
|
|
if _settings is None:
|
|
_settings = Settings()
|
|
_settings.asegurar_data_dir()
|
|
return _settings
|