Files
hechakuaa_bot/database.py
markosbenitez fa72ced5a6 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>
2026-05-26 08:06:36 -03:00

182 lines
6.1 KiB
Python

"""
database.py — Persistencia de tenants y preferencias.
SQLite vía aiosqlite. En v1 esto se reemplaza por Postgres con el mismo interface público.
Schema:
- tenants: un registro por abogado registrado.
Las credenciales van cifradas con vault.py (Fernet derivada por chat_id).
- Migraciones: directorio migrations/*.sql, ordenadas por nombre.
schema_migrations lleva registro de las ya aplicadas.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
import aiosqlite
import structlog
logger = structlog.get_logger(__name__)
_conn: aiosqlite.Connection | None = None
def get_conn() -> aiosqlite.Connection:
"""Retorna la conexión activa. Llamar solo después de run_migrations()."""
return _assert_conn()
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
async def run_migrations(db_path: Path) -> None:
"""
Aplica migraciones pendientes desde migrations/*.sql en orden alfabético.
Idempotente: versiones ya aplicadas se saltean.
Reemplaza init_db() — llamar una sola vez al arrancar.
"""
global _conn
db_path.parent.mkdir(parents=True, exist_ok=True)
_conn = await aiosqlite.connect(str(db_path))
_conn.row_factory = aiosqlite.Row
await _conn.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)
""")
await _conn.commit()
async with _conn.execute("SELECT version FROM schema_migrations") as cur:
applied = {row["version"] for row in await cur.fetchall()}
migrations_dir = Path(__file__).parent / "migrations"
for migration_file in sorted(migrations_dir.glob("*.sql")):
version = migration_file.stem
if version in applied:
logger.debug("migration_ya_aplicada", version=version)
continue
sql = migration_file.read_text(encoding="utf-8")
for stmt in sql.split(";"):
stmt = stmt.strip()
# Saltear líneas de comentario puras y sentencias vacías
if stmt and not all(line.startswith("--") for line in stmt.splitlines() if line.strip()):
await _conn.execute(stmt)
await _conn.execute(
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
(version, _now_iso()),
)
await _conn.commit()
logger.info("migration_aplicada", version=version)
logger.info("database_iniciada", path=str(db_path))
async def init_db(db_path: Path) -> None:
"""Alias de run_migrations() para compatibilidad con scripts existentes."""
await run_migrations(db_path)
def _assert_conn() -> aiosqlite.Connection:
if _conn is None:
raise RuntimeError("DB no inicializada — llamar a init_db() antes de usar database.")
return _conn
async def get_tenant(chat_id: int) -> dict | None:
"""Retorna el tenant o None si no existe."""
conn = _assert_conn()
async with conn.execute(
"SELECT * FROM tenants WHERE chat_id = ?", (chat_id,)
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def save_tenant(chat_id: int, **fields: object) -> None:
"""
Crear o actualizar un tenant. Los kwargs son los campos de la tabla.
Hace upsert: inserta si no existe, actualiza si ya existe.
"""
conn = _assert_conn()
fields["last_active"] = _now_iso()
existing = await get_tenant(chat_id)
if existing is None:
fields.setdefault("created_at", _now_iso())
columns = ["chat_id", *fields.keys()]
placeholders = ", ".join("?" * len(columns))
values = [chat_id, *fields.values()]
await conn.execute(
f"INSERT INTO tenants ({', '.join(columns)}) VALUES ({placeholders})",
values,
)
logger.info("tenant_creado", chat_id=chat_id)
else:
set_clause = ", ".join(f"{k} = ?" for k in fields)
values_upd = [*fields.values(), chat_id]
await conn.execute(
f"UPDATE tenants SET {set_clause} WHERE chat_id = ?",
values_upd,
)
logger.info("tenant_actualizado", chat_id=chat_id, campos=list(fields.keys()))
await conn.commit()
async def list_active_tenants() -> list[dict]:
"""Retorna todos los tenants con estado='activo'."""
conn = _assert_conn()
async with conn.execute(
"SELECT * FROM tenants WHERE estado = 'activo'"
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def delete_tenant(chat_id: int) -> None:
"""Elimina el tenant y sus datos de la DB."""
conn = _assert_conn()
await conn.execute("DELETE FROM tenants WHERE chat_id = ?", (chat_id,))
await conn.commit()
logger.info("tenant_eliminado", chat_id=chat_id)
# ── Invite codes ──────────────────────────────────────────────────────────────
async def crear_invite_code(codigo: str) -> None:
"""Inserta un código de invitación de un solo uso."""
conn = _assert_conn()
await conn.execute(
"INSERT INTO invite_codes (codigo, creado_at) VALUES (?, ?)",
(codigo, _now_iso()),
)
await conn.commit()
logger.info("invite_code_creado", codigo=codigo[:4] + "****")
async def verificar_y_consumir_invite_code(codigo: str, chat_id: int) -> bool:
"""
Verifica y consume un código de invitación en una sola operación atómica.
Usa UPDATE con condición AND usado=0 en vez de SELECT+UPDATE para eliminar la
race condition TOCTOU: dos requests concurrentes con el mismo código solo
pueden actualizar la fila una vez — el segundo UPDATE afecta 0 rows y retorna False.
"""
conn = _assert_conn()
cur = await conn.execute(
"UPDATE invite_codes SET usado = 1, usado_por = ?, usado_at = ? "
"WHERE codigo = ? AND usado = 0",
(chat_id, _now_iso(), codigo),
)
await conn.commit()
if cur.rowcount > 0:
logger.info("invite_code_consumido", chat_id=chat_id)
return True
return False