Seguridad
El motor es un servicio interno: vive en tu red privada y no se publica en internet. Encima de eso, separa a quién administra el motor de quién lo usa, guarda los secretos sellados y no deja datos personales en los logs ni en la auditoría.
Dos identidades que no se mezclan
Sección titulada «Dos identidades que no se mezclan»| Operadores | Apps cliente | |
|---|---|---|
| Quién | Las personas que administran el motor | Tus sistemas (el CRM, otros proyectos) |
| Rutas | /admin/api y el panel /admin/ |
/v1 |
| Credencial | Cookie de sesión mw_sesion + encabezado X-CSRF-Token |
Authorization: Bearer mw_live_… (o mw_test_…) |
| Permisos | Rol: superadmin u operador |
Scopes de la key y, si quieres, una lista de instancias |
| Se crea con | motorctl (el primero) |
El panel: un superadmin crea la app y emite la key |
Una key de app nunca abre la administración, y una sesión de operador nunca vale en /v1. La app sale
de la credencial, nunca del cuerpo del pedido: todo lo que una app ve o toca se filtra por su app.
Operadores
Sección titulada «Operadores»| Rol | Puede |
|---|---|
operador |
Ver todo y operar las instancias del día a día: crearlas, vincularlas, reconectar, pausar, asignarles un proxy; ver entregas, bridges, streams, backups, la auditoría y las métricas; probar los Ajustes. |
superadmin |
Además, lo que toca credenciales y políticas: crear, editar, suspender y reactivar apps; emitir, rotar y revocar keys; guardar Ajustes; crear, editar y borrar suscripciones y bridges; replays; destinos permitidos; proxies; desvincular y archivar instancias; marcar el canario; hacer y verificar backups; exportar la configuración. |
El rol se exige en el servidor: una acción de superadmin con otro rol responde 403 («Tu rol de operador no permite esta acción: pídesela a un superadmin.»). El panel, además, oculta esos controles.
Los operadores se administran con motorctl (no hay pantalla para eso en el panel):
motorctl operador crear --email operador@empresa.com --nombre "Nombre" --rol operadormotorctl operador listarmotorctl operador revocar-sesiones --email operador@empresa.comLa contraseña se lee de la entrada estándar, nunca de un argumento ni de una variable. Tiene de 12 a 72
caracteres y se guarda con bcrypt. revocar-sesiones cierra todas las sesiones abiertas de ese
operador.
Sesión y CSRF
Sección titulada «Sesión y CSRF»- Al entrar (
POST /admin/api/sesion), el motor crea una sesión y guarda solo el HMAC de su token. - La cookie
mw_sesionesHttpOnlyySameSite=Strict; en producción (APP_ENV=produccion) tambiénSecure, así que el panel necesita HTTPS (salvo enlocalhost). - La sesión dura Ajustes → Seguridad → Duración de la sesión (12 h por omisión).
- Cada mutación (todo lo que no es
GET) pide el encabezadoX-CSRF-Token, un HMAC del token de la sesión que el panel lee de la cookiemw_csrfo de la respuesta del login. Sin él, 403 («Falta el token CSRF o no es válido. Recarga la página.»). - Una sesión vencida o revocada es 401 y el panel vuelve al login.
Intentos de acceso
Sección titulada «Intentos de acceso»El login cuenta los intentos fallidos por correo y por IP en ventanas de 15 minutos (Ajustes →
Seguridad → Intentos de acceso, 5 por omisión). Al llegar al tope responde 429 con
Retry-After. Un correo que no existe tarda lo mismo que uno que existe, para no revelar cuentas.
La IP es la del socket: el motor no confía en X-Forwarded-For. Si pones un proxy inverso delante del
panel, el límite por IP cuenta la del proxy; el límite por correo sigue valiendo. El contador vive en
memoria y se reinicia con el proceso.
Apps cliente y API keys
Sección titulada «Apps cliente y API keys»Keys con hash
Sección titulada «Keys con hash»- Un token tiene la forma
mw_live_<8 hex>_<43 caracteres>(mw_test_…para las keys de prueba). - El motor guarda su prefijo y su HMAC, nunca el token. El panel lo muestra una sola vez, al emitirlo o rotarlo; si se pierde, se rota.
- Una key puede vencer (fecha opcional), se rota (sale una nueva con los mismos scopes y la anterior deja de valer al instante) o se revoca. Suspender la app corta todas sus keys hasta reactivarla.
- Un token inválido, vencido, revocado o de una app suspendida es 401, sin decir cuál de las cuatro.
Cada operación de /v1 pide un scope; sin él, 403 («Tu API key no tiene el scope de esta
operación.»). Son 33, por dominio (instances:*, messages:*, events:read, subscriptions:manage,
deliveries:replay, media:*, groups:*, newsletters:*, catalog:read…). Dale a cada key solo los
que necesita. El catálogo completo está en Apps, instancias y scopes, y
GET /v1/whoami dice qué scopes tiene una key.
Restricción por instancias
Sección titulada «Restricción por instancias»Al emitir una key puedes elegir instancias de la app. Una key restringida solo ve y opera esas instancias: las demás se comportan como si no existieran (404, sin decir de quién son), sus eventos no aparecen en el ledger ni en los streams, y la key no puede crear instancias (403). Una lista vacía alcanza a todas las instancias de la app.
La clave maestra
Sección titulada «La clave maestra»APP_MASTER_KEY (32 caracteres o más) es la raíz de todas las claves del motor. De ella deriva, con
HKDF, una clave distinta para cada uso: lo sellado para un uso no se abre con otro, y la maestra nunca se
usa directamente.
| Uso | Cómo |
|---|---|
| Los secretos de Ajustes (claves de S3/MinIO, token del catálogo) | Sellados con AES-256-GCM. Nunca salen por la API: se ven como «configurado». |
| Los secretos de las suscripciones, de los bridges y la credencial de cada proxy | Sellados. |
| Los payloads del ledger, de los comandos y de las proyecciones (mensajes, contactos, historial, catálogo) y los retos de vinculación | Sellados. |
| Las API keys, las sesiones y las identidades (JID) | Se guarda su HMAC, no el valor. |
| Los permisos de descarga de la multimedia y los cursores | Firmados con HMAC. |
| Los backups | Cifrados con una clave de datos envuelta por una clave derivada; el historial guarda solo la huella. |
Las tablas del store de whatsmeow (las sesiones de los números) no van selladas con esta clave: están en claro en Postgres, protegidas por el acceso a la base, y cifradas en los backups. Por eso la base va con rol propio, sin puertos públicos y con el volumen protegido.
Guarda la clave en el gestor de secretos de la infraestructura, nunca en el repositorio, en un archivo junto a los backups ni en un ticket. Guarda además una copia offline aparte de los backups.
Rotar la clave maestra
Sección titulada «Rotar la clave maestra»Si la clave se filtra:
- Solo la clave (sin la base ni los backups): la clave sola no da acceso. Restringe quién lee la variable del despliegue, Postgres y el destino de los backups, y mueve los backups hechos con esa clave a un destino más estricto.
- La clave y la base (o un backup): trata como comprometido todo lo sellado y las sesiones de WhatsApp. Rota en su origen las credenciales de S3/MinIO, de los brokers y de los proxies, y vuelve a cargarlas; Rota el secreto de cada suscripción y avisa a los receptores; rota todas las API keys; cierra las sesiones de los operadores; y desvincula y vuelve a vincular los números.
- Para pasar a una clave nueva hoy, la vía es una instalación nueva con esa clave (base vacía, Ajustes, apps, keys y suscripciones de nuevo, y vincular los números). La historia anterior queda en los backups viejos, legibles solo con la clave anterior.
El procedimiento completo es docs/runbooks/rotar-clave-maestra.md del repositorio.
Logs sin datos personales
Sección titulada «Logs sin datos personales»Los logs son JSON (slog) y tienen dos barreras:
- Nadie loguea cuerpos. El log de cada pedido HTTP anota el método, la plantilla de la ruta
(
/v1/instances/{id}, no el path real), el estado, la duración y un ID de pedido propio. Nunca la query string ni el cuerpo. El motor no acepta elX-Request-Iddel cliente: genera el suyo. - El logger redacta. Un atributo con nombre de secreto se reemplaza por
[oculto], y en cualquier texto (también en los errores) se tapan los tokensmw_…, las URL de Postgres con credenciales, los secretos de webhook, los JID, los teléfonos y los retos de vinculación (QR y códigos).
Los errores al cliente tampoco filtran detalles internos: un error inesperado se registra y responde un 500 genérico («Algo falló en el motor. Intenta de nuevo.»). Un error de validación nunca devuelve el valor que se mandó.
Auditoría
Sección titulada «Auditoría»Cada cambio de un operador, de una app o de motorctl deja una entrada en la auditoría, en la misma
transacción que el cambio: si el cambio no se guarda, la entrada tampoco.
- Cada entrada dice el actor (
operador,clientecon el prefijo de su key,cliosistema), la acción (por ejemplosesion.iniciar,suscripcion.rotar,ajustes.probar), la entidad y solo los campos que importan. - Cada servicio declara qué campos registra; además, una segunda barrera oculta cualquier campo con nombre de secreto y redacta tokens, JID y teléfonos.
- Se lee con
GET /admin/api/auditoria(con sesión de operador; filtroentidad, paginado). El panel no tiene una pantalla de auditoría.
Quedan auditados, entre otros: el login y el logout, las apps y las keys, los Ajustes y sus pruebas, las suscripciones y sus rotaciones y replays, las acciones sobre una instancia, la exportación de la configuración y la salida del modo restaurado.
Webhooks firmados
Sección titulada «Webhooks firmados»Cada entrega lleva X-MW-Signature: v1=<hex>, un HMAC-SHA256 con el secreto de la suscripción
sobre timestamp + "." + delivery_id + "." + body, junto con X-MW-Key-Id, X-MW-Timestamp,
X-MW-Delivery-Id y X-MW-Event-Id.
- El secreto se muestra una sola vez (al crear o rotar) y el motor lo guarda sellado.
- Al rotar, el anterior sigue firmando durante Ajustes → Webhooks → Ventana de rotación (60
minutos por omisión): en ese tiempo llegan dos firmas, separadas por comas, en el mismo orden que los
X-MW-Key-Id. - Tu receptor verifica la firma sobre el cuerpo crudo, rechaza un timestamp viejo y deduplica por
event_id. Un 401 o 403 del receptor suspende la suscripción (alertaentrega_suspendida).
Cómo verificarla, con código: Webhooks.
Destinos permitidos
Sección titulada «Destinos permitidos»El motor solo entrega a hosts aprobados. Es la defensa contra que una URL de webhook se use para llegar a algo interno (SSRF):
- Una suscripción solo puede apuntar a un host de la lista de destinos permitidos: la global
(Ajustes → Webhooks → Destinos permitidos) más la de cada app (su ficha → Destinos
permitidos). Una entrada sin puerto permite cualquier puerto; con puerto, solo ese. Fuera de la
lista, el alta responde 422 y cada intento falla con
destino_no_permitido. - Las direcciones de metadatos de la nube (como
169.254.0.0/16y los nombresmetadata…) se bloquean siempre, aunque estén en la lista. localhosty la dirección «sin especificar» solo valen si la lista nombra esa IP literal.- La IP se valida al conectar, no solo al guardar: un nombre permitido que pasa a resolver a una IP
bloqueada falla con
destino_bloqueado. Los redirects no se siguen. - La URL no puede llevar usuario ni contraseña.
Los bridges (NATS, RabbitMQ, Kafka, SQS) usan la misma lista de su app, y los proxies de transporte, su propia lista (Ajustes → Proxies → Destinos permitidos), con las mismas reglas.
La red privada
Sección titulada «La red privada»- El motor no se expone a internet. Ni
/v1, ni/admin, ni/docs. Publícalo solo en una interfaz privada o en la red de Docker que comparte con sus apps. Los operadores entran por VPN o por un HTTPS privado. Ver Despliegue y backup. - Necesita salida hacia WhatsApp. Si usas un proxy de transporte, va en Ajustes; el motor no lee
HTTP_PROXYniHTTPS_PROXYdel entorno. - Encabezados: toda respuesta lleva
X-Content-Type-Options: nosniff,Referrer-Policy: no-referreryX-Frame-Options: DENY; las de/v1y/admin/api, además,Cache-Control: no-store. - El entorno: solo cuatro variables (
DATABASE_URL,APP_MASTER_KEY,APP_ENV,PORT). Nunca un archivo con claves; todo lo demás va en Ajustes, con los secretos sellados. - El contenedor:
nonroot, de solo lectura, sin capacidades (cap_drop: ALL) y conno-new-privileges.
La referencia en /docs
Sección titulada «La referencia en /docs»El binario sirve en /docs una entrada a la documentación y la referencia de la API pública /v1 de
esa versión, con Scalar servido por el propio motor: sin CDN, sin fuentes externas y sin telemetría.
/openapi.json y /docs/openapi.json publican solo el contrato de /v1 y de los eventos. La
referencia de la administración (/admin/api) no se publica ahí: está en
API de administración.