Files
message_bus/reports/0007-2026-06-07-unibus-security-hardening-2.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

17 KiB
Raw Blame History

Report 0007 — Hardening de seguridad 2 del bus unibus (cierre de la re-auditoría 0006)

  • Fecha: 07/06/2026
  • Autor: agente implementador (Claude Opus 4.8)
  • Ámbito: projects/message_bus/apps/unibus (sub-repo dataforge/unibus). Implementación del issue 0005 (fases 0005a0005e), que cierra los hallazgos NUEVOS de la re-auditoría red-team (report 0006). Trabajo confinado al sub-repo unibus; el repo padre fn_registry no se tocó.
  • Estado: done
  • Punto de partida: master post-0003 (fb0291a), unibus v0.6.0. Resultado: unibus v0.7.0 (df3b62a).
  • Método: trunk-based, una rama corta por fase (issue/0005x-*), commits atómicos, merge --no-ff a master tras tests verdes. Cada hallazgo se verificó vivo en el master actual antes de arreglarlo, y cada fix lleva un test adversarial portado de la re-auditoría que se confirmó como guard real (falla al revertir el fix).

Resumen ejecutivo

Las 5 fases del issue 0005 están implementadas, testeadas y mergeadas a master. Los tres bloqueantes nuevos (N1 CVEs, N3 spoof por firma omitida, N2 DoS por concurrencia), el gap N4 (TLS no forzado) y el residual H4 (ACL por subject sin cablear) quedan cerrados, con un residual menor documentado en H4 y una nota operativa sobre el cliente.

Fase Hallazgo Sev Estado Evidencia
0005a N1 — 16 CVEs alcanzables (14 nats-server + 2 stdlib) Alto CERRADO govulncheck 16 → 0 alcanzables
0005b N3 — spoof por firma omitida (Sig==nil) en rooms SignMsgs Alto CERRADO TestReaudit_SigNilSpoof
0005c N2 — DoS por concurrencia (memoria agregada sin cota) Medio-Alto CERRADO (residual factor 2 documentado) TestReaudit_DoSConcurrency
0005d N4 — TLS del control plane no forzado en bind público Medio CERRADO TestGap_PublicEnforceNoTLS
0005e H4 — ACL por subject existía pero no estaba cableada Medio (residual) CERRADO el wildcard; residual $JS.API.> documentado TestReaudit_H4_WildcardMetadataLeak

Veredicto: la exposición pública pasa de "NO-aún" a "sí-con-condiciones". Ver §Veredicto para las condiciones exactas.


Verificación de partida (hallazgos vivos en el master actual)

Antes de tocar nada, govulncheck confirmó N1 vivo sobre el master post-0003:

$ CGO_ENABLED=0 govulncheck ./...
...
Your code is affected by 16 vulnerabilities from 1 module and the Go standard library.

N3 vivo (pkg/client/client.go:802: if info.Policy.SignMsgs && f.Sig != nil), N4 vivo (validateBootConfig permitía público+enforce+sin-TLS), H4 vivo en el binario (cmd/membershipd/main.go:141 usaba NewNkeyAuthenticator, NO el NewNkeyAuthenticatorACL que existía huérfano desde 0003e). N2 vivo (handlePutBlob hace io.ReadAll sin cota agregada).


0005a — N1: CVEs en dependencias (Alto) → CERRADO

Hallazgo: 16 vulnerabilidades alcanzables: 14 en github.com/nats-io/nats-server/v2@v2.10.22 (servidor NATS embebido, expuesto público en el deploy decidido) + 2 en la stdlib de Go (GO-2026-5039 net/textproto, GO-2026-5037 crypto/x509).

Fix: go get github.com/nats-io/nats-server/v2@v2.11.15 (arrastra nats.go v1.49.0, nkeys v0.4.15, jwt v2.8.1 y otras transitivas); directiva go 1.25.0 → 1.26.4 para que la toolchain incluya los dos fixes de stdlib. Cambio de go.mod/go.sum justificado por CVE (excepción explícita a la regla "no tocar deps").

Verificación:

$ CGO_ENABLED=0 govulncheck ./...
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
$ CGO_ENABLED=0 go build ./... && go vet ./... && go test -count=1 ./...
ok  github.com/enmanuel/unibus/cmd/membershipd   0.003s
ok  github.com/enmanuel/unibus/pkg/blobstore     0.100s
ok  github.com/enmanuel/unibus/pkg/busauth       0.006s
ok  github.com/enmanuel/unibus/pkg/client        5.741s
ok  github.com/enmanuel/unibus/pkg/embeddednats  6.002s   <- cluster/JetStream e2e de 0003
ok  github.com/enmanuel/unibus/pkg/frame         0.002s
ok  github.com/enmanuel/unibus/pkg/membership    3.032s

El bump del servidor NATS no rompió el cluster ni el plano durable (el e2e multi-nodo de 0003 en pkg/embeddednats sigue verde). Las 13 vulnerabilidades que govulncheck aún lista están en módulos requeridos pero NO llamados (no alcanzables) — fuera del objetivo "0 alcanzables".

Gaps: no se construyó un exploit por CVE; el bump de versión es la acción correcta con independencia del exploitability individual.


0005b — N3: spoof por firma omitida (Alto) → CERRADO

Hallazgo: pkg/client/client.go::processFrame verificaba la firma SOLO cuando el frame la traía (info.Policy.SignMsgs && f.Sig != nil). En una room que EXIGE firma, un atacante con acceso al data plane publicaba un frame con Sig==nil y Sender forjado y el receptor lo aceptaba como auténtico.

Fix: en una room SignMsgs, un frame sin firma es en sí mismo un rechazo:

if info.Policy.SignMsgs {
    if f.Sig == nil { return }   // firma exigida por la política pero ausente: drop
    // verify ...
}

Las rooms no firmadas (ModeNATS) no se ven afectadas.

Verificación (pkg/client/sig_nil_spoof_test.go, TestReaudit_SigNilSpoof): golden (frame firmado válido entregado) + error (frame Sig==nil con Sender forjado en room SignMsgs → drop) + edge (room sin SignMsgs sigue entregando frames sin firma).

$ CGO_ENABLED=0 go test -run TestReaudit_SigNilSpoof ./pkg/client/
ok  github.com/enmanuel/unibus/pkg/client  0.448s

# Confirmación de guard real (fix revertido):
--- FAIL: TestReaudit_SigNilSpoof (0.26s)
    sig_nil_spoof_test.go:115: SIG-NIL SPOOF: receiver accepted an unsigned frame with a forged Sender in a SignMsgs room

Gaps: ninguno; el bug era preexistente (no introducido por 0001/0004) y queda cerrado en ambos planes (efímero y persistido comparten processFrame).


0005c — N2: DoS por concurrencia (Medio-Alto) → CERRADO (residual documentado)

Hallazgo: el límite por-request (16 MiB blob / 1 MiB control) y el rate-limit por-IP no acotan la memoria AGREGADA. La re-auditoría midió 40 subidas concurrentes de 16 MiB → ~1.42 GB RSS, y un flood multi-IP (botnet) escala sin techo porque el rate-limit es por-IP.

Fix: limiter global no bloqueante de bytes en vuelo (pkg/membership/inflight.go, inflightLimiter con contador atómico CAS). ServeHTTP reserva el peor caso de un POST (el ceiling de su ruta) antes de leer el body y lo libera al terminar; cuando se alcanza la cota global (maxInflightBytes = 128 MiB) los POST adicionales se descartan con 503 (backpressure, sin parquear goroutines). Los GET no consumen presupuesto.

Nota de diseño: la primitiva se implementó dentro de unibus en vez de delegarla al registry fn_registry. Razón doble: (1) el trabajo está confinado al sub-repo por instrucción explícita, y (2) functions/core del registry arrastra dependencias transitivas que requieren CGO (mattn/go-sqlite3) y módulos externos incompatibles con el build CGO_ENABLED=0 de unibus, así que unibus no podría importarlo aunque quisiera. Queda documentado en los comentarios de inflight.go.

Verificación (pkg/membership/inflight_test.go + dos_concurrency_test.go):

$ CGO_ENABLED=0 go test -run 'TestInflightLimiter|TestReaudit_DoSConcurrency' ./pkg/membership/
--- PASS: TestInflightLimiterBasics
--- PASS: TestInflightLimiterDisabled
--- PASS: TestInflightLimiterConcurrent
--- PASS: TestReaudit_DoSConcurrency
    dos_concurrency_test.go:146: N2 bound: 40 uploads -> 200=3 503=37, RSS delta 92940 kB (bound 262144 kB), cap 48 MiB

40 subidas concurrentes de 16 MiB desde IPs distintas (la forma multi-IP que el rate-limit por-IP NO defiende) contra una cota de test de 48 MiB: solo 3 bufferizan a la vez (200=3), el resto se descarta (503=37), y el RSS sube ~93 MiB en vez de ~1.42 GB — acotado, no lineal con N. Confirmación de guard real (limiter deshabilitado, cota 0):

--- FAIL: TestReaudit_DoSConcurrency
    dos_concurrency_test.go:125: a concurrent flood of 40 uploads past the cap should shed some with 503; got 200=40 503=0

Invariante de concurrencia verificada con el race detector:

$ CGO_ENABLED=1 go test -race -run TestInflightLimiter ./pkg/membership/
ok  github.com/enmanuel/unibus/pkg/membership  1.336s

Gaps / residual: bajo enforce el body se bufferiza dos veces (verificación de firma en ServeHTTP + io.ReadAll del handler), así que el RSS real es ~2× los bytes reservados. Cerrar eso del todo implica streamear los blobs a disco en vez de io.ReadAll en RAM — se solapa con la cuota/GC de blobs del issue 0002 (H9). La cota CONTABILIZADA sí es fiel; el factor 2 es un residual menor declarado.


0005d — N4: TLS del control plane no forzado (Medio) → CERRADO

Hallazgo: el guard validateBootConfig cerraba "público sin enforce" y "TLS sin enforce", pero permitía público + enforce + SIN --tls-cert → el control plane servía metadata (subjects, pubkeys, sealed keys, grafo social) sobre HTTP plano público (H5 reapareciendo).

Fix: validateBootConfig ahora también rechaza un --bind no-loopback salvo que --tls-cert y --tls-key estén ambos presentes. El dev loopback no se ve afectado.

Verificación (cmd/membershipd/config_test.go):

$ CGO_ENABLED=0 go test -run 'TestGap_PublicEnforceNoTLS|TestBootConfigPolicy' ./cmd/membershipd/
--- PASS: TestGap_PublicEnforceNoTLS
--- PASS: TestBootConfigPolicy   (16 subcasos)

TestGap_PublicEnforceNoTLS: validateBootConfig("0.0.0.0", AuthEnforce, "", "") ahora retorna error mencionando --tls-cert (golden público+enforce+TLS permitido; edge loopback-sin-TLS permitido). La tabla TestBootConfigPolicy se amplió: public+enforce+notls, +certonly, +keyonly, lan-ip+enforce+notls ahora rechazados.

Gaps: ninguno; el guard es función pura y la tabla cubre las combinaciones bind × mode × cert/key.


0005e — H4: ACL por subject (Medio, residual) → CERRADO el wildcard; residual documentado

Hallazgo y evaluación: la ACL por subject existía desde 0003e (membership.SubjectACLFor + busauth.NewNkeyAuthenticatorACL, con test unitario TestSubjectACLIsolation), pero el binario nunca la usaba: cmd/membershipd instalaba el NewNkeyAuthenticator plano. Por eso, en producción, un registrado NO-miembro podía abrir una conexión NATS cruda, Subscribe(">") y captar los subjects de todas las rooms + la actividad de JetStream (el payload seguía siendo ciphertext E2E, pero la metadata se filtraba). El ataque TestReaudit_H4_WildcardMetadataLeak confirmó que el acl.go de 0003 NO lo cerraba — porque no estaba cableado.

Fix:

  • Nuevo adaptador de producción busauth.PermissionsFromSubjects (convierte la función que deriva subjects en la PermissionsFunc que espera el authenticator; concede los subjects como allow de publish Y subscribe; un error de derivación falla cerrado). Vive en busauth para que membership no dependa de nats-server.
  • cmd/membershipd, bajo enforce, ahora instala NewNkeyAuthenticatorACL(store.IsAuthorized, PermissionsFromSubjects(membership.SubjectACLFor(store))).
  • El helper del test ahora delega en el adaptador de producción, así que los tests ejercitan el camino real.

Verificación (pkg/membership/acl_test.go):

$ CGO_ENABLED=0 go test -run 'TestReaudit_H4_WildcardMetadataLeak|TestSubjectACLIsolation|TestRefreshSessionGainsNewRoom' ./pkg/membership/
--- PASS: TestSubjectACLIsolation
--- PASS: TestReaudit_H4_WildcardMetadataLeak
--- PASS: TestRefreshSessionGainsNewRoom

TestReaudit_H4_WildcardMetadataLeak: un no-miembro registrado recibe permission violation al Subscribe(">") y al subscribirse al subject exacto de una room ajena; la miembro sigue pub/subscribiendo su room y el no-miembro no capta nada. Confirmación de guard real (authenticator plano, el cableado pre-0005e):

--- FAIL: TestReaudit_H4_WildcardMetadataLeak
    acl_test.go:269: a non-member's Subscribe(">") must raise a permissions violation (wildcard metadata leak still open)

Gaps / residual (documentado, no cerrado aquí):

  1. $JS.API.> compartido. Todos los peers tienen permitido $JS.API.> (necesario para que JetStream funcione por-conexión). Un peer que se suscriba específicamente a $JS.API.> aún puede observar requests de gestión de streams cuyos subjects llevan el nombre de stream derivado del room id. Cierre completo = aislamiento por NATS accounts/permissions por identidad — diferido a la línea 0003 (descentralización). El leak de alto impacto que la auditoría explotó (el subject de la room + advisories vía Subscribe(">")) sí queda cerrado.
  2. Nota operativa (gap funcional, no de seguridad): NATS congela los permisos al conectar, así que un cliente debe llamar client.RefreshSession tras un cambio de membresía para ganar el subject de la nueva room. El patrón existe y está testeado (TestRefreshSessionGainsNewRoom), pero cmd/chat y cmd/worker aún NO lo invocan. Es un gap funcional del cliente a cerrar ANTES de un despliegue enforce+ACL real (sin él, un cliente no podrá pub/sub en rooms creadas/unidas tras conectar). No afecta la postura de seguridad (falla cerrado: deniega, no filtra).

Verificación global (master df3b62a, unibus v0.7.0)

$ CGO_ENABLED=0 go build ./... && CGO_ENABLED=0 go vet ./...
BUILD+VET OK

$ CGO_ENABLED=0 go test -count=1 ./...
ok  github.com/enmanuel/unibus/cmd/membershipd   0.004s
ok  github.com/enmanuel/unibus/pkg/blobstore     0.096s
ok  github.com/enmanuel/unibus/pkg/busauth       0.007s
ok  github.com/enmanuel/unibus/pkg/client        6.238s
ok  github.com/enmanuel/unibus/pkg/embeddednats  5.899s
ok  github.com/enmanuel/unibus/pkg/frame         0.002s
ok  github.com/enmanuel/unibus/pkg/membership    4.093s

$ CGO_ENABLED=0 govulncheck ./...
No vulnerabilities found.  Your code is affected by 0 vulnerabilities.

$ CGO_ENABLED=1 go test -race -run TestInflightLimiter ./pkg/membership/
ok  github.com/enmanuel/unibus/pkg/membership  1.336s

Historia (first-parent):

df3b62a Merge quick/0005-bump-close: unibus 0.7.0 + close issue 0005
a4bbe82 Merge issue/0005e-acl-wire: wire per-subject ACL into membershipd (audit H4)
a2ec78c Merge issue/0005d-tls-guard: require TLS on public bind (audit N4)
db8618d Merge issue/0005c-inflight: global in-flight byte limiter bounds aggregate memory (audit N2)
0f79708 Merge issue/0005b-sig-nil: drop unsigned frames in SignMsgs rooms (audit N3)
88b4791 Merge issue/0005a-cve-bump: bump nats-server to v2.11.15 + go1.26.4 (16 CVEs -> 0 reachable)

Veredicto: exposición pública

Pasa de "NO-aún" (report 0006) a "SÍ, con condiciones". Los cinco hallazgos de la re-auditoría están cerrados con evidencia ejecutable; los residuales restantes son acotados y conocidos.

Condiciones para el despliegue público (0001f / 0003f, las ejecuta el humano):

  1. Arrancar con --bus-auth enforce y --tls-cert/--tls-key (el guard ahora lo obliga en bind no-loopback; el control plane sirve HTTPS y el data plane nkey+TLS+ACL).
  2. Cerrar el gap del cliente antes de exponer: cmd/chat y cmd/worker deben llamar client.RefreshSession tras crear/unirse a rooms, o no operarán bajo la ACL. Es el único bloqueante funcional pendiente para enforce+ACL.
  3. Asumir el residual de metadata de $JS.API.> (un peer dedicado puede inferir nombres de stream) hasta que se implementen NATS accounts por identidad — apropiado tratarlo en la línea 0003.
  4. Tener presente el factor 2 de RAM por request bajo enforce (doble buffer); el maxInflightBytes de 128 MiB lo cubre con margen para un bus interactivo, pero el cierre definitivo es el streaming de blobs a disco (issue 0002 / H9).

Fuera de alcance de este issue (otros tickets): H9 (cuota/GC de blobs → 0002), H10/H11 (AEAD nonce / nonce+ts en firma de owner → bajo, futuro), H8 (custodia de la CA → operacional), y la auditoría dedicada de la superficie nueva de 0003 (cluster routes auth, jetstreamStore KV fail-closed, nonce-cache replicado, failover), que la re-auditoría 0006 NO cubrió (auditó pre-0003).


Gaps de este trabajo (honestidad)

  • La primitiva del limiter (0005c) se duplicó conceptualmente respecto al registry (se implementó local en unibus) por la restricción de scope + la incompatibilidad CGO de functions/core. No es deuda silenciosa: está documentada en el código y aquí.
  • El wiring de main.go (0005e) no tiene un test que arranque el binario completo; se validó por inspección + build + el test del MECANISMO (startACLNats usa el mismo adaptador de producción, y la regresión con el authenticator plano confirma que es el wiring lo que cierra el leak).
  • El gap de RefreshSession en chat/worker (0005e) se documenta pero no se cierra: es trabajo funcional del cliente, fuera del scope de seguridad de este issue.
  • Toda la verificación es en una sola máquina (enmanuel, Linux). El e2e multi-nodo de 0003 corre embebido en proceso, no en VPS reales.