29fe688b7a
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
247 lines
17 KiB
Markdown
247 lines
17 KiB
Markdown
# 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.
|