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

247 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```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.