diff --git a/DEPLOY.md b/DEPLOY.md index 6b6e489..12a283f 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -429,6 +429,9 @@ WEBHOOK_SECRET= # ← dejar vacío por ahora, completar en el pas # ── HEALTH CHECK ───────────────────────────────────────────────────────────── HEALTH_PORT=8080 # dejar así HEALTH_ALERT_HOURS=2 # alerta al admin si el poller no actualizó en 2 horas +# Token para /health (detallado) y /health/history — generar con: +# python3 -c "import secrets; print(secrets.token_hex(32))" +HEALTH_AUTH_TOKEN= # ← COMPLETAR (recomendado) # ── POLLING ────────────────────────────────────────────────────────────────── POLL_INTERVAL_MINUTES=60 # revisar el PJ cada 60 minutos @@ -577,6 +580,14 @@ Pegar **exactamente** este contenido (reemplazá el nombre de dominio si usás u # /etc/nginx/sites-available/pedrito # Reverse proxy para Pedrito Hechakuaa +# ── Rate limiting ───────────────────────────────────────────────────────────── +# Limita requests por IP para mitigar escaneo automatizado y ataques de fuerza bruta. +# La zona se define fuera del bloque server (en el contexto http). +# Si tenés otros sites en este Nginx, mover estas líneas a /etc/nginx/nginx.conf +# dentro del bloque http { }. +limit_req_zone $binary_remote_addr zone=pedrito_webhook:10m rate=30r/s; +limit_req_zone $binary_remote_addr zone=pedrito_health:10m rate=10r/m; + server { listen 80; server_name pedrito.inqualityhq.com; @@ -594,14 +605,19 @@ server { include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - # Seguridad: no exponer versión de Nginx + # Seguridad: no exponer versión de Nginx ni headers informativos server_tokens off; + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options DENY; # Tamaño máximo de payload (Telegram puede enviar archivos) client_max_body_size 20M; - # ── Health check (acceso público para monitoreo) ────────────────────────── + # ── Health check ────────────────────────────────────────────────────────── + # /health es público (respuesta mínima sin datos sensibles) + # /health/history requiere Bearer token — el bot lo rechaza sin él location /health { + limit_req zone=pedrito_health burst=5 nodelay; proxy_pass http://127.0.0.1:8080/health; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -609,25 +625,31 @@ server { } location /health/history { + limit_req zone=pedrito_health burst=3 nodelay; proxy_pass http://127.0.0.1:8080/health/history; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + # Pasar Authorization header para que el bot pueda validar el Bearer token + proxy_set_header Authorization $http_authorization; proxy_read_timeout 10s; } # ── Webhook de Telegram ─────────────────────────────────────────────────── + # Solo Telegram envía aquí — los IPs de Telegram son bien conocidos (149.154.x, 91.108.x) + # El bot valida el X-Telegram-Bot-Api-Secret-Token internamente location /webhook { + limit_req zone=pedrito_webhook burst=50 nodelay; proxy_pass http://127.0.0.1:8080/webhook; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # Pasar el header de autenticación del webhook de Telegram proxy_set_header X-Telegram-Bot-Api-Secret-Token $http_x_telegram_bot_api_secret_token; proxy_read_timeout 30s; } # ── Denegar cualquier otra ruta ─────────────────────────────────────────── + # Respuesta genérica — no revelar que hay un bot ni su propósito location / { return 404; } diff --git a/config.py b/config.py index 8fba0d2..cd612a6 100644 --- a/config.py +++ b/config.py @@ -57,6 +57,10 @@ class Settings(BaseSettings): # 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 diff --git a/database.py b/database.py index 96f1bb7..7177485 100644 --- a/database.py +++ b/database.py @@ -162,23 +162,20 @@ async def crear_invite_code(codigo: str) -> None: async def verificar_y_consumir_invite_code(codigo: str, chat_id: int) -> bool: """ - Verifica que el código existe y no fue usado, lo marca como consumido y - retorna True. Retorna False si no existe o ya fue usado. - Operación atómica en una sola transacción SQLite. + 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() - async with conn.execute( - "SELECT usado FROM invite_codes WHERE codigo = ?", (codigo,) - ) as cur: - row = await cur.fetchone() - - if row is None or row["usado"]: - return False - - await conn.execute( - "UPDATE invite_codes SET usado = 1, usado_por = ?, usado_at = ? WHERE codigo = ?", + 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() - logger.info("invite_code_consumido", chat_id=chat_id) - return True + if cur.rowcount > 0: + logger.info("invite_code_consumido", chat_id=chat_id) + return True + return False diff --git a/health.py b/health.py index 14491b3..251e883 100644 --- a/health.py +++ b/health.py @@ -2,20 +2,31 @@ health.py — Servidor HTTP para health check y webhook de Telegram. Rutas: - GET /health → JSON con estado actual del bot - GET /health/history → últimos N eventos del audit_log (?limit=50) + GET /health → estado mínimo (público) | estado completo (con Bearer token) + GET /health/history → últimos N eventos del audit_log — REQUIERE Bearer token POST /webhook → recibe updates de Telegram (solo en modo webhook) -Modo webhook opt-in: solo se activa si settings.webhook_url está configurado. -En modo long-polling, /webhook no se registra. +Seguridad: + - /health sin auth: solo devuelve {"status": "ok"|"degraded", "timestamp": "..."} + Sin conteos de tenants ni timing que revele inteligencia operacional. + - /health con auth: respuesta completa con métricas internas. + - /health/history siempre requiere auth. + - chat_ids en el historial se enmascaran como hashes opacos (no reversibles desde HTTP). + - Webhook valida X-Telegram-Bot-Api-Secret-Token con compare_digest. + +Configurar en .env: + HEALTH_AUTH_TOKEN= + (generar con: python -c "import secrets; print(secrets.token_hex(32))") """ from __future__ import annotations +import asyncio +import hashlib import json +import secrets from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING -import asyncio import aiohttp.web import structlog from telegram import Update @@ -29,7 +40,7 @@ logger = structlog.get_logger(__name__) # ── Estado global del poller ────────────────────────────────────────────────── _last_poll_at: datetime | None = None _last_poll_tenant_count: int = 0 -_alert_sent: bool = False # evitar spam de alertas consecutivas +_alert_sent: bool = False def record_poll(tenant_count: int) -> None: @@ -40,6 +51,17 @@ def record_poll(tenant_count: int) -> None: _alert_sent = False +def _mask_chat_id(chat_id: int | None) -> str | None: + """ + Convierte un chat_id en un identificador opaco no reversible. + Preserva la capacidad de correlacionar eventos del mismo usuario + sin exponer el Telegram ID real. + """ + if chat_id is None: + return None + return "usr_" + hashlib.sha256(str(chat_id).encode()).hexdigest()[:8] + + # ── Servidor ────────────────────────────────────────────────────────────────── class HealthServer: @@ -61,7 +83,11 @@ class HealthServer: await self._runner.setup() site = aiohttp.web.TCPSite(self._runner, "0.0.0.0", self._settings.health_port) await site.start() - logger.info("health_server_iniciado", port=self._settings.health_port) + logger.info( + "health_server_iniciado", + port=self._settings.health_port, + auth_configurada=bool(self._settings.health_auth_token), + ) self._watcher_task = asyncio.create_task(self._watcher_loop()) @@ -76,37 +102,60 @@ class HealthServer: await self._runner.cleanup() logger.info("health_server_detenido") + # ── Auth ────────────────────────────────────────────────────────────────── + + def _is_authenticated(self, request: aiohttp.web.Request) -> bool: + """ + Valida el header Authorization: Bearer . + Si health_auth_token no está configurado, siempre retorna False + (los endpoints protegidos devuelven respuesta mínima, no error). + """ + if not self._settings.health_auth_token: + return False + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return False + provided = auth[7:] + # compare_digest previene timing attacks sobre el token de admin + return secrets.compare_digest(provided, self._settings.health_auth_token) + # ── Handlers ───────────────────────────────────────────────────────────── async def _handle_health(self, request: aiohttp.web.Request) -> aiohttp.web.Response: - from database import get_conn - conn = get_conn() - - async with conn.execute( - "SELECT COUNT(*) as n FROM tenants WHERE estado = 'activo'" - ) as cur: - row = await cur.fetchone() - tenant_count = row["n"] if row else 0 - now = datetime.now(timezone.utc) - seconds_since_poll: int | None = None stale = False - if _last_poll_at: - delta = now - _last_poll_at - seconds_since_poll = int(delta.total_seconds()) - stale = delta > timedelta(hours=self._settings.health_alert_hours) + stale = (now - _last_poll_at) > timedelta(hours=self._settings.health_alert_hours) - data = { + # Respuesta pública mínima — no revela conteos ni timing operacional + data: dict = { "status": "degraded" if stale else "ok", "timestamp": now.isoformat(), - "tenants_activos": tenant_count, - "last_poll": _last_poll_at.isoformat() if _last_poll_at else None, - "last_poll_tenants": _last_poll_tenant_count, - "seconds_since_last_poll": seconds_since_poll, - "poll_interval_minutes": self._settings.poll_interval_minutes, - "webhook_mode": bool(self._settings.webhook_url), } + + # Respuesta extendida solo con auth válida + if self._is_authenticated(request): + from database import get_conn + conn = get_conn() + async with conn.execute( + "SELECT COUNT(*) as n FROM tenants WHERE estado = 'activo'" + ) as cur: + row = await cur.fetchone() + tenant_count = row["n"] if row else 0 + + seconds_since_poll: int | None = None + if _last_poll_at: + seconds_since_poll = int((now - _last_poll_at).total_seconds()) + + data.update({ + "tenants_activos": tenant_count, + "last_poll": _last_poll_at.isoformat() if _last_poll_at else None, + "last_poll_tenants": _last_poll_tenant_count, + "seconds_since_last_poll": seconds_since_poll, + "poll_interval_minutes": self._settings.poll_interval_minutes, + "webhook_mode": bool(self._settings.webhook_url), + }) + return aiohttp.web.Response( text=json.dumps(data, indent=2), content_type="application/json", @@ -114,6 +163,20 @@ class HealthServer: ) async def _handle_history(self, request: aiohttp.web.Request) -> aiohttp.web.Response: + # Este endpoint siempre requiere autenticación — contiene PII (chat_ids) + if not self._is_authenticated(request): + logger.warning( + "health_history_acceso_denegado", + ip=request.remote, + ua=request.headers.get("User-Agent", "")[:80], + ) + return aiohttp.web.Response( + status=401, + text=json.dumps({"error": "Authentication required"}), + content_type="application/json", + headers={"WWW-Authenticate": 'Bearer realm="pedrito-health"'}, + ) + from database import get_conn conn = get_conn() @@ -129,16 +192,26 @@ class HealthServer: ) as cur: rows = await cur.fetchall() + # Enmascarar chat_ids: el consumidor puede correlacionar eventos del mismo usuario + # pero no puede determinar la identidad real sin acceso a la DB. + records = [] + for r in rows: + row_dict = dict(r) + row_dict["chat_id"] = _mask_chat_id(row_dict.get("chat_id")) + records.append(row_dict) + return aiohttp.web.Response( - text=json.dumps([dict(r) for r in rows], indent=2), + text=json.dumps(records, indent=2), content_type="application/json", ) async def _handle_webhook(self, request: aiohttp.web.Request) -> aiohttp.web.Response: - secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "") - if self._settings.webhook_secret and secret != self._settings.webhook_secret: - logger.warning("webhook_token_invalido", ip=request.remote) - return aiohttp.web.Response(status=403, text="Forbidden") + provided = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "") + if self._settings.webhook_secret: + if not secrets.compare_digest(provided, self._settings.webhook_secret): + logger.warning("webhook_token_invalido", ip=request.remote) + # Respuesta genérica — no revelar si es auth vs otro error + return aiohttp.web.Response(status=404, text="Not Found") try: body = await request.json() @@ -155,7 +228,7 @@ class HealthServer: """Alerta al admin si el poller lleva más de health_alert_hours sin actualizar.""" global _alert_sent while True: - await asyncio.sleep(300) # revisar cada 5 minutos + await asyncio.sleep(300) if _last_poll_at is None or _alert_sent: continue delta = datetime.now(timezone.utc) - _last_poll_at diff --git a/main.py b/main.py index e09867d..d227275 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ Al arrancar: aplica migraciones y migra el tenant principal del .env si no exist from __future__ import annotations import asyncio +import os import signal import sys @@ -71,8 +72,34 @@ async def _migrar_tenant_principal(settings) -> None: logger.info("tenant_principal_migrado", chat_id=chat_id) +def _limpiar_env_sensibles() -> None: + """ + Elimina variables sensibles del entorno del proceso después de cargar Settings. + + Protege contra: + - cat /proc//environ (cualquier usuario con acceso al PID) + - herencia accidental de env vars por subprocesos + - stack traces que vuelquen os.environ + + NO protege contra 'docker inspect' (que lee la config del contenedor, no el env live). + Para eso usar Docker secrets con Swarm mode. + """ + _sensibles = ( + "FERNET_KEY", + "TELEGRAM_BOT_TOKEN", + "PJ_USUARIO_ENC", + "PJ_PASSWORD_ENC", + "WEBHOOK_SECRET", + "HEALTH_AUTH_TOKEN", + "INVITE_CODE", + ) + for var in _sensibles: + os.environ.pop(var, None) + + async def main() -> None: settings = get_settings() + _limpiar_env_sensibles() logger.info( "pedrito_hechakuaa_iniciando", diff --git a/onboarding.py b/onboarding.py index 9c8def5..7c49a3e 100644 --- a/onboarding.py +++ b/onboarding.py @@ -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) diff --git a/telegram_bot.py b/telegram_bot.py index a665430..0a66de0 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -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