Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 KiB
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-repodataforge/unibus). Implementación del issue 0005 (fases 0005a–0005e), que cierra los hallazgos NUEVOS de la re-auditoría red-team (report 0006). Trabajo confinado al sub-repo unibus; el repo padrefn_registryno 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-ffa 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/coredel registry arrastra dependencias transitivas que requieren CGO (mattn/go-sqlite3) y módulos externos incompatibles con el buildCGO_ENABLED=0de unibus, así que unibus no podría importarlo aunque quisiera. Queda documentado en los comentarios deinflight.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 laPermissionsFuncque espera el authenticator; concede los subjects como allow de publish Y subscribe; un error de derivación falla cerrado). Vive enbusauthpara quemembershipno dependa denats-server. cmd/membershipd, bajo enforce, ahora instalaNewNkeyAuthenticatorACL(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í):
$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íaSubscribe(">")) sí queda cerrado.- Nota operativa (gap funcional, no de seguridad): NATS congela los permisos al conectar, así que un cliente debe llamar
client.RefreshSessiontras un cambio de membresía para ganar el subject de la nueva room. El patrón existe y está testeado (TestRefreshSessionGainsNewRoom), perocmd/chatycmd/workeraú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):
- Arrancar con
--bus-auth enforcey--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). - Cerrar el gap del cliente antes de exponer:
cmd/chatycmd/workerdeben llamarclient.RefreshSessiontras crear/unirse a rooms, o no operarán bajo la ACL. Es el único bloqueante funcional pendiente para enforce+ACL. - 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. - Tener presente el factor 2 de RAM por request bajo enforce (doble buffer); el
maxInflightBytesde 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 (startACLNatsusa 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
RefreshSessionen 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.