Files
message_bus/reports/0005-2026-06-07-unibus-security-hardening.md
T
egutierrez 29fe688b7a ahora si funciona
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 16:23:53 +02:00

16 KiB
Raw Blame History

Report 0005 — unibus: hardening de seguridad (issue 0004, fases 0004a0004f)

  • Fecha: 07/06/2026
  • Autor: agente (Claude Opus 4.8)
  • Ámbito: projects/message_bus/apps/unibus (sub-repo dataforge/unibus). Paquetes membership, client, embeddednats, cmd/membershipd; deploy/tls.
  • Estado: done — entrega 0004a0004f en master del sub-repo. Despliegue (0001f/0003f) lo ejecuta el humano.
  • Origen: cierra los hallazgos de la auditoría red-team del report 0004 (projects/message_bus/reports/0004-2026-06-07-unibus-security-audit.md).

Resumen

La auditoría 0004 concluyó que la autenticación del bus es sólida pero faltaban autorización, disponibilidad y confidencialidad de metadata — exactamente lo que un bus público necesita. Veredicto de la auditoría: NO exponer público hoy (1 crítico + 4 altos bloqueantes).

Este issue cierra esos hallazgos. Cada fase es una rama TBD propia, mergeada a master con merge --no-ff tras tests verdes, y cada una porta el test adversarial del auditor (TestAudit_*) como regresión que ahora arroja el resultado seguro.

Historia (vista --first-parent):

d483c90 Merge issue/0004f-medium-fixes  (H6/H7/H12)
957b728 Merge issue/0004e-control-tls   (H5)
0d56c3c Merge issue/0004d-dataplane-acl (H4)
47ff74d Merge issue/0004c-membership-authz (H3)
d742f91 Merge issue/0004b-failopen-guard (H2)
01e2ee1 Merge issue/0004a-dos-limit     (H1)

Verificación global (sin caché):

$ CGO_ENABLED=0 go build ./...   # OK
$ CGO_ENABLED=0 go vet ./...     # limpio
$ CGO_ENABLED=0 go test -count=1 ./...
  ok  cmd/membershipd  0.003s
  ok  pkg/busauth      0.007s
  ok  pkg/client       5.353s
  ok  pkg/frame        0.002s
  ok  pkg/membership   0.862s

Fase 0004a — H1 (Crítico): límite de cuerpo + anti-DoS pre-auth

Hallazgo cerrado. El middleware Server.ServeHTTP hacía io.ReadAll(r.Body) sin límite y antes de authenticate(); handlePutBlob repetía el io.ReadAll. Un atacante sin credenciales forzaba buffering ilimitado en RAM (400 MB → 898 MB RSS según el auditor).

Fix.

  • http.MaxBytesReader en el middleware antes del io.ReadAll, con ceiling por ruta: 1 MiB para JSON de control, 16 MiB para /blobs. Un Content-Length declarado por encima del ceiling se rechaza sin bufferizar un solo byte; un sender que miente (chunked) trip­ea MaxBytesReader al llegar al ceiling.
  • handlePutBlob mapea el corte a 413 en todos los modos de auth.
  • Rate-limit por IP (token-bucket golang.org/x/time/rate, ya en el module graph; mantenido en-paquete como glue de transporte, igual que el nonceCache en 0003) que descarta floods antes de auth y de leer el body.
  • http.Server.MaxHeaderBytes + ReadHeaderTimeout.

Evidencia (regresión, RSS acotado):

$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestAudit_DoSBodyLimitNoAuth|TestBlobLimit|TestControlBodyLimit|TestRateLimitPerIP' -v
  --- PASS: TestAudit_DoSBodyLimitNoAuth   (cuerpo 400 MiB sin firma -> 413; delta RSS < 96 MiB vs 400 MB+ del ataque)
  --- PASS: TestBlobLimitGoldenAndBoundary (blob normal -> 200; exactamente en el ceiling -> 200; 1 byte sobre -> 413)
  --- PASS: TestControlBodyLimit           (cuerpo control > 1 MiB -> 413)
  --- PASS: TestRateLimitPerIP             (flood de una IP -> 429; IPs distintas no se estrangulan)

Gaps. El blob se sigue bufferizando en RAM hasta 16 MiB (la firma cubre sha256(body), hay que leer todo). El streaming a disco que sugería el auditor queda fuera de alcance (encaja con la cuota/GC de blobs del issue 0002).


Fase 0004b — H2 (Alto): cerrar el fail-open de configuración

Hallazgo cerrado. Default --bus-auth off; el nkey solo se activa en enforce; TLS era flag independiente. --bind 0.0.0.0 --tls-cert … sin enforce dejaba el bus abierto con apariencia de seguro.

Fix. validateBootConfig (función pura, llamada tras el flag parse) hace log.Fatal ante dos formas inseguras: bind no-loopback sin enforce, y --tls-cert/--tls-key sin enforce. Un arranque inseguro es imposible (el proceso sale).

Evidencia:

$ CGO_ENABLED=0 go test ./cmd/membershipd/ -run 'TestAudit_FailOpenTLSWithoutAuth|TestBootConfigPolicy' -v
  --- PASS: TestAudit_FailOpenTLSWithoutAuth  (TLS sin enforce -> rechazado; el cliente no tiene a qué conectarse)
  --- PASS: TestBootConfigPolicy              (12 casos: public+enforce OK, loopback dev OK, public/LAN/empty sin enforce y TLS-sin-enforce rechazados)

$ ./membershipd --bind 0.0.0.0 --bus-auth off
  refusing to start: --bind "0.0.0.0" is not loopback but --bus-auth is "off"; a public bind requires --bus-auth enforce (or bind 127.0.0.1 for local dev)

Gaps. El guard clasifica como "público" cualquier hostname que no resuelva a un literal loopback (conservador). mTLS (client certs) como segunda barrera no se implementó (no estaba en alcance).


Fase 0004c — H3 (Alto): autorización por pertenencia en el control plane

Hallazgo cerrado. "Autorizado" significaba "registrado", no "miembro". Los GET de room exponían subject, lista de miembros (con sign_pub+kex_pub), el directorio de rooms de cualquiera y la sealed_key ajena a cualquier registrado.

Fix. El middleware propaga el endpoint del firmante autenticado al handler vía context. Los handlers de room exigen pertenencia: GET /rooms/{id} y /rooms/{id}/members requieren ser miembro; /rooms/{id}/key sirve la clave sellada solo a su propio endpoint (y solo a un miembro); /members/{endpoint}/rooms se restringe al propio endpoint del firmante. La autorización se salta solo cuando no hay firmante autenticado (AuthOff dev / pass-through soft), preservando el comportamiento legacy.

Evidencia:

$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestAudit_HorizontalMetadataLeak|TestAuth' -v
  --- PASS: TestAudit_HorizontalMetadataLeak  (bob no-miembro -> 403 en room/members/directorio/clave; alice owner y carol miembro -> 200; carol solo su propia clave)
  --- PASS: TestAuthGoldenAccepted / Replay / ClockSkew / TamperedBody / MissingHeaders / HealthExempt / Soft / Off  (suite de auth intacta)

El flujo de membresía legítimo sigue verde (TestSecureBusEndToEnd: A invita B, B accede a su room/clave bajo enforce).

Gaps. Ninguno relevante para el hallazgo. La autorización se evalúa por request contra SQLite (fail-closed ante error de query).


Fase 0004d — H4 (Alto): control de acceso en el data plane NATS (mínimo defensivo + documentado)

Hallazgo. El authenticator nkey solo decide "registrado sí/no"; no hay permisos por subject. Cualquier registrado se suscribe/publica en cualquier subject; las rooms ModeNATS (cleartext) quedan expuestas.

Estrategia elegida y su límite (documentada en apps/unibus/dev/0004d-dataplane-acl.md). La ACL por subject completa derivada de pertenencia no cabe aquí: NATS evalúa los permisos una vez al conectar y no los re-evalúa, pero los clientes de unibus hacen connect → create/join → publish en la misma conexión (TestSecureBusEndToEnd). Permisos estáticos prohibirían al propio owner publicar en la room que acaba de crear; el modelo de reconexión dinámica pertenece al rediseño de sesión del issue 0003. Por eso se implementa el mínimo defensivo que el issue autoriza:

Fix. Server.RequireEncryptedRooms (que membershipd activa en cualquier bind no-loopback) rechaza crear rooms cleartext. En despliegue público toda room es E2E, así que el contenido de los mensajes permanece confidencial aunque el transporte no aísle subjects: un sniffer recibe solo ciphertext AEAD sin clave.

Evidencia:

$ CGO_ENABLED=0 go test ./pkg/membership/ -run TestRequireEncryptedRoomsRejectsCleartext -v
  --- PASS  (cleartext create -> 403; encrypted -> 201; flag off -> cleartext permitido de nuevo)

$ CGO_ENABLED=0 go test ./pkg/client/ -run TestAudit_NoSubjectACL -v
  --- PASS  (postura pública: ModeNATS rechazada; bob miembro descifra "internal: salary numbers";
            eve se suscribe RAW al subject del data plane y recibe SOLO ciphertext — nonce AEAD no vacío,
            sin el plaintext — cerrando el "eve lee internal: salary numbers" del auditor)

Gaps (residual, por diseño, tracked para 0003). Un registrado que ya conoce un subject puede observar que está activo, los tamaños de ciphertext y la cadencia (metadata de tráfico), y puede publicar bytes arbitrarios (que fallan AEAD/firma y se descartan: spam, no break de confidencialidad/integridad). La ACL por subject real requiere el modelo de reconexión dinámica de 0003.


Fase 0004e — H5 (Alto, público): TLS en el control plane

Hallazgo cerrado. El HTTP :8470 iba firmado pero sin TLS → toda la metadata (subjects, endpoints, pubkeys, sealed keys, hashes, grafo social) legible por un MITM.

Fix. membershipd sirve el control plane sobre TLS (ListenAndServeTLS, MinVersion 1.2) con el mismo cert firmado por la CA propia que el data plane, cuando --tls-cert está presente (el guard 0004b ya exige enforce con esos flags). El cliente recibe Options.CtrlTLS separado para pinear la CA en el http.Client, independiente del TLS de NATS. Connect setea ambos planos desde la única CA y rechaza un control-plane http:// cuando se le pasa una CA (la firma da integridad, no confidencialidad).

Evidencia (regresión + runtime):

$ CGO_ENABLED=0 go test ./pkg/client/ -run 'TestConnectRequiresHTTPSWithCA|TestControlPlaneOverTLS' -v
  --- PASS: TestConnectRequiresHTTPSWithCA  (CA + http:// -> error que apunta a https)
  --- PASS: TestControlPlaneOverTLS         (con la CA pineada -> request OK; sin la CA -> handshake falla)

$ ./membershipd --bind 127.0.0.1 --bus-auth enforce --tls-cert deploy/tls/server.crt --tls-key deploy/tls/server.key ...
  [membershipd] HTTPS control-plane API: https://127.0.0.1:18473
  [membershipd] control-plane TLS: ON (deploy/tls/server.crt)
$ curl --cacert deploy/tls/ca.crt -o /dev/null -w '%{http_code}' https://127.0.0.1:18473/healthz   # 200
$ curl http://127.0.0.1:18473/healthz                                                              # 400 + log "client sent an HTTP request to an HTTPS server"

Gaps. Connect mantiene su firma; los binarios worker/chat (flag --ca) y mobile.NewSession deben pasar una URL de control-plane https:// cuando pasan CA — anotado para el deploy. No se reescribió playground/ ni mobile/ (fuera de alcance; el binding mobile no cambió de firma).


Fase 0004f — medios: owner binding + nonce-cache + error leak (H6/H7/H12)

H6 (owner spoof). handleCreateRoom liga ahora el owner declarado al firmante autenticado: el endpoint id y la clave de firma deben ser los del firmante. Un registrado ya no crea rooms a nombre de otra identidad.

H7 (nonce-cache poison pre-auth). IsAuthorized corre antes de tocar el nonceCache, así que una identidad no registrada (las claves Ed25519 son gratis) ya no siembra nonces. El cache se reescribió con poda O(expired) — bajo TTL constante el orden de inserción es el de expiración, así que la cola FIFO poda solo lo caducado, en vez del scan O(n) sobre todo el mapa bajo el mutex — más un cap de tamaño con evicción del más viejo. Es el prerequisito del nonce-store replicado del issue 0003.

H12 (error leak). Los errores internos de store/blob se loguean y se sustituyen por un mensaje genérico al cliente vía writeServerErr; ya no se filtran fragmentos SQL ni rutas. Los 4xx con mensaje crafteado (owner-sig, validación) se conservan.

Evidencia:

$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestAudit_OwnerSpoof|TestAudit_NonceCachePoisonPreAuth|TestNonceCache' -v
  --- PASS: TestAudit_OwnerSpoof              (owner endpoint o clave ajenos -> 403; self-owned -> 201)
  --- PASS: TestAudit_NonceCachePoisonPreAuth (no-registrado repite nonce -> sigue "not authorized", nunca "replayed" => no se cacheó;
                                               replay de identidad AUTORIZADA -> sigue rechazado "replayed nonce")
  --- PASS: TestNonceCacheRememberPrune        (acepta nuevo, rechaza replay, re-acepta tras TTL)
  --- PASS: TestNonceCacheCapBounded           (500 inserts, TTL largo -> cache acotado al cap; order/seen sin drift)

Gaps. Bajo presión de cap, evictar el nonce más viejo (TTL casi cumplido) podría, en una ventana muy estrecha, permitir el replay de ese nonce concreto; mitigado porque solo se cachea tráfico autorizado (post-H7) y el rate-limit (0004a) estrangula. La firma de owner del payload (invite/rekey) sigue sin nonce/ts propios (H11): cubierta en la práctica por el envelope enforce; reforzar si se relaja enforce.


Cobertura de tests (DoD)

Cada fase aporta golden + ≥2 edge + ≥1 error path con evidencia ejecutable, y porta el TestAudit_* correspondiente:

Hallazgo Test adversarial portado Paquete
H1 TestAudit_DoSBodyLimitNoAuth pkg/membership
H2 TestAudit_FailOpenTLSWithoutAuth cmd/membershipd
H3 TestAudit_HorizontalMetadataLeak pkg/membership
H4 TestAudit_NoSubjectACL pkg/client
H6 TestAudit_OwnerSpoof pkg/membership
H7 TestAudit_NonceCachePoisonPreAuth pkg/membership

Fuera de alcance (encolado en otros issues)

  • H9 (cuota/GC de blobs) → issue 0002 (media v2).
  • H10 (AEAD nonce 12B → XChaCha o rekey por volumen) → futuro, issue propio si se necesitan rooms de muy alto volumen.
  • H11 (firma de owner sin nonce/ts) → cubierto por el envelope enforce; documentado.
  • H8 (custodia de la CA: generar en om, ca.key fuera del PC) → operacional del deploy.
  • ACL por subject completa en el data plane (residual de H4) → issue 0003 (requiere reconexión dinámica del cliente / permisos replicados).
  • govulncheck sobre nats-server/nats.go/modernc → paso de CI aparte.

Veredicto re-evaluado

La auditoría 0004 dijo "NO exponer público" por 1 crítico + 4 altos. Tras este hardening:

  • H1 (DoS) cerrado: cuerpos acotados + rate-limit; RSS no se dispara (regresión con medición real).
  • H2 (fail-open) cerrado: arranque público inseguro imposible.
  • H3 (autz horizontal) cerrado: metadata de room solo para miembros; clave sellada solo para su dueño.
  • H4 (data plane) mitigado al mínimo defensivo: contenido siempre E2E en público; ACL por subject documentada y diferida a 0003.
  • H5 (MITM control plane) cerrado: control plane sobre TLS con CA propia; cliente exige https.
  • Medios H6/H7/H12 cerrados.

Veredicto: de "NO" a "SÍ, con condiciones operacionales". El bus es seguro para exposición pública si se cumplen, en el deploy:

  1. --bus-auth enforce + --tls-cert/--tls-key (el guard ya lo fuerza en bind público; con bind público las rooms quedan E2E-only automáticamente).
  2. Restart=always en el unit systemd (un SIGTERM limpio es exit 0; on-failure no reiniciaría — gotcha conocido del ecosistema).
  3. CA generada y custodiada fuera del host de aplicación (ca.key offline / en pass); el ca.crt commiteado se trata como CA de dev (H8, operacional).
  4. Clientes recompilados contra este pkg/client y conectando con client.Connect(...ca.crt) y URL de control-plane https://.

Condición residual aceptada: en el data plane, un peer registrado puede observar metadata de tráfico (no contenido) y hacer spam descartado; la ACL por subject real llega con el issue 0003. Para un despliegue solo-WireGuard ese residual es irrelevante (la red ya restringe).


Gaps / pendientes honestos de este trabajo

  • No se midió el DoS bajo concurrencia real (N conexiones de varios GB): la regresión demuestra el cap de RAM con requests in-process (medición /proc/self/status), no el OOM bajo carga concurrente.
  • El blob se bufferiza hasta 16 MiB en RAM (necesario por la firma sobre sha256(body)); el streaming a disco queda para 0002.
  • No se ejecutó govulncheck sobre las dependencias NATS/sqlite (paso de CI aparte).
  • playground/, mobile/, unibots no migrados: un cliente que conecte en claro/sin nkey reabre superficie; deben recompilarse contra este pkg/client y pasar https:// + ca.crt.
  • La estrategia de 0004d es deliberadamente el mínimo defensivo; la confidencialidad de contenido está garantizada, la de metadata de tráfico no — explícito y tracked para 0003.