# 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 0005a–0005e), 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: ```go 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.