Files
message_bus/reports/0006-2026-06-07-unibus-security-reaudit.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

218 lines
15 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 0006 — Re-auditoría de seguridad del bus unibus (verificación del fix 0004 + cobertura ampliada)
- **Fecha:** 07/06/2026
- **Autor:** agente auditor (Claude Opus 4.8) — red-team adversarial, sin modificar producción
- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`). Verificación de los fixes del issue 0004 (H1H7, H12) sobre los hallazgos del report 0004, más cobertura nueva: `govulncheck`, fuzz del frame parser, DoS bajo concurrencia, clientes no migrados, y vectores nuevos introducidos por el propio fix.
- **Commit auditado:** `618f6b6` ("chore(0004): close issue, bump unibus to 0.5.0") — el HEAD de master cuando empezó esta re-auditoría.
- **Método:** auditoría sobre un **clon aislado en `/tmp`** del commit `618f6b6` (ver §"Nota de proceso": el working tree del sub-repo estaba siendo modificado en paralelo por otro agente y en un momento no compilaba). Build + suite verdes en el clon; instancia efímera `membershipd` real en `enforce` + TLS de control plane y de datos (puertos altos, DB en `/tmp`, certs generados con `deploy/tls/generate-certs.sh`); bypasses reales (curl, tests Go adversariales white-box, conexiones NATS raw con nkey). Todo artefacto efímero eliminado; el working tree del sub-repo real **no fue tocado** por esta auditoría.
---
## Resumen ejecutivo
**El fix 0004 cerró los cuatro hallazgos de alto riesgo del report 0004 — verificado con evidencia.** Quedan, sin embargo, motivos para NO exponer aún el bus a internet público: aparecieron tres bloqueantes nuevos (dependencias con CVEs, DoS por concurrencia, spoof por firma omitida) y dos gaps en el propio fix.
**Veredicto: sigue siendo NO-aún para exposición pública, pero mucho más cerca.** El trabajo de hardening fue real y de calidad; los hallazgos restantes son acotados y con fix claro.
Cerrados y verificados (report 0004 → fix 0004):
| Original | Estado | Evidencia resumida |
|---|---|---|
| H1 DoS pre-auth ilimitado | **CERRADO** | 400 MB sin firma → `413` con RSS plano (antes 898 MB). |
| H2 fail-open por config | **CERRADO** | `validateBootConfig` aborta público-sin-enforce y TLS-sin-enforce. |
| H3 autz horizontal | **CERRADO** | no-miembro → `403` en room/members/others-rooms/others-key. |
| H5 control plane MITM | **CERRADO (capacidad)** | control plane sirve HTTPS; cliente fuerza `https://` y pinea la CA. |
| H6 owner spoof | **CERRADO** | crear room a nombre de otro → `403`. |
| H7 nonce-cache poison | **CERRADO** | `IsAuthorized` antes del cache; poda O(expired) + cap. |
| H12 error leak | **CERRADO** | `writeServerErr` registra el detalle y devuelve mensaje genérico. |
Pendientes / nuevos (esta re-auditoría):
| # | Sev | Qué |
|---|---|---|
| N1 | **Alto** | 14 CVEs en `nats-server v2.10.22` (servidor embebido, expuesto público) + 2 en la stdlib de Go. Fix disponible. |
| N2 | **Medio-Alto** | DoS por concurrencia: el límite por-request (16 MiB) + rate-limit per-IP NO acotan la memoria agregada. 40 subidas simultáneas → 1.42 GB RSS. |
| N3 | **Alto** (cleartext-signed) / Medio (E2E) | Spoof por firma omitida: un frame con `Sig==nil` salta la verificación en una room `SignMsgs` → suplantación de `Sender`. |
| N4 | **Medio** | El control plane TLS (H5) no se fuerza: público + enforce + **sin** `--tls-cert` arranca el control plane en HTTP plano. |
| H4 | **Medio** (residual conocido) | Sin ACL por subject en NATS: un registrado con `Subscribe(">")` capta subjects + metadata de todas las rooms. Mitigado parcialmente (E2E forzado en público), no cerrado. |
---
## Verificación de los fixes (regresión, con evidencia ejecutable)
### H1 — DoS pre-auth → CERRADO
`Server.ServeHTTP` ahora: rate-limit per-IP primero → rechazo por `Content-Length` sobre el techo → `http.MaxBytesReader` antes de `io.ReadAll`. Techos: 1 MiB control / 16 MiB blobs. `http.Server.MaxHeaderBytes` 1 MiB.
```
# 400 MB sin firma, con Content-Length:
H1a code: 413 body: {"error":"request body too large"} RSS base 20988 kB -> peak 21216 kB (antes: 898 004 kB)
# 400 MB chunked (sin Content-Length): MaxBytesReader corta
H1b code: 413 RSS peak 58 096 kB
# rate-limit: 80 GET rápidos desde 1 IP
H1c -> 58×401 + 22×429
```
La amplificación de memoria por request quedó eliminada (≈ +0.2 MB con Content-Length; el chunked deja un transitorio acotado de ~37 MB). **Pero ver N2: la concurrencia no está acotada.**
### H2 — fail-open → CERRADO
`cmd/membershipd/config.go::validateBootConfig` (función pura, testeada) rechaza el arranque inseguro: bind no-loopback sin `enforce`, y `--tls-cert/--tls-key` sin `enforce`. `isLoopbackBind` trata `""` y los hostnames no resolubles como público (conservador). Verificado: público+soft y tls+off → error. **Pero ver N4: no exige TLS en bind público.**
### H3 — autorización por pertenencia → CERRADO
`requireMember` + `signerEndpoint` (del contexto, sólo tras autenticar). Aplicado en `GET /rooms/{id}`, `/members`, `/members/{ep}/rooms` (ep==signer), `/rooms/{id}/key` (endpoint==signer + member). `handleCreateRoom` liga el owner al firmante.
```
H3 owner-room -> 200 (el miembro sí accede)
H3 bob-room -> 403 forbidden: not a member of this room
H3 bob-members -> 403 forbidden: not a member of this room
H3 bob-others-rooms -> 403 forbidden: may only list your own rooms
H3 bob-others-key -> 403 forbidden: may only fetch your own sealed key
H3 bob-own-rooms -> 200 (su propio directorio sigue accesible)
```
### H5 — control plane TLS → CERRADO (capacidad)
`main.go` sirve `ListenAndServeTLS` cuando hay `--tls-cert`; `client.Connect` **rechaza** un `ctrlURL` no-`https://` cuando se aporta CA y pinea la CA en el `http.Transport`.
```
http plano a puerto TLS -> 400 ; https + CA -> 200 ; https sin CA -> handshake fail
```
### H6 — owner spoof → CERRADO
```
H6 spoof create (bob firma, owner=victim) -> 403 forbidden: room owner must be the authenticated signer
```
### H7 — nonce-cache poison → CERRADO
`authenticate` mueve `IsAuthorized`/`GetUser` **antes** de `rememberOrReject`; el cache pasó a poda O(expired) con orden de inserción == orden de expiración + cap 100 000.
```
H7 unregistered 1st -> 401 identity not authorized
H7 unregistered 2nd -> 401 identity not authorized (NO "replayed nonce": el no-autorizado nunca entra al cache)
H7 authorized replay -> 1ª pasa auth, 2ª 401 replayed nonce (anti-replay sigue intacto)
```
### H12 — error leak → CERRADO
`writeServerErr` registra el detalle interno (SQL/paths) y devuelve `"internal error"` genérico al cliente.
---
## Hallazgos nuevos / residuales
### N1 — Dependencias con CVEs conocidas (Alto)
`govulncheck ./...` sobre el clon: **"Your code is affected by 16 vulnerabilities from 1 module and the Go standard library."**
- **14 en `github.com/nats-io/nats-server/v2@v2.10.22`** — el servidor NATS embebido, que en el despliegue decidido queda **expuesto a internet**. IDs: GO-2026-4841/4837/4836/4835/4834/4833/4832/4831/4830/4829/4828/4827/4533, GO-2025-3600. Fix: **bump a `v2.11.15`** (cubre todas).
- **2 en la stdlib (go1.26.3):** GO-2026-5039 (`net/textproto` — parsing de cabeceras HTTP, relevante al control plane) y GO-2026-5037 (`crypto/x509` — validación de certificados, relevante al TLS). Fix: **toolchain go1.26.4**.
`govulncheck` reporta traces que **alcanzan** el código vulnerable (no son sólo "presentes"). Para un bus público es bloqueante: actualizar antes de 0001f/0003f.
### N2 — DoS por concurrencia (Medio-Alto)
El fix H1 acota cada request, pero **no la memoria agregada**: el rate-limit es por-IP (token bucket burst 40) y no hay límite de conexiones concurrentes ni de bytes en vuelo. Subiendo blobs de 16 MiB (el techo) en paralelo:
```
40 subidas de 16 MiB simultáneas desde 1 IP, sin firma -> RSS base 22 336 kB -> peak 1 419 036 kB (~1.42 GB)
```
40 = el burst per-IP, así que la ráfaga entra entera antes de throttle. Desde varias IPs (botnet) escala sin techo: el rate-limit per-IP no defiende contra distribuido ni acota memoria total. **Fix:** límite global de conexiones concurrentes y/o de bytes-en-vuelo; streamear el blob a disco en vez de `io.ReadAll` en RAM; o bajar `maxBlobBytes`. (Se combina con H9: blobs sin cuota.)
### N3 — Spoof por firma omitida en rooms firmadas (Alto en cleartext-signed / Medio en E2E)
`pkg/client/client.go::processFrame`: `if info.Policy.SignMsgs && f.Sig != nil { verify... }`. La verificación **sólo corre si el frame trae firma**. Un atacante con acceso al data plane publica un frame con `Sig==nil` y `Sender` forjado, y el receptor lo acepta como auténtico.
```
TestReaudit_SigNilSpoof:
alice crea room {Encrypt:false, SignMsgs:true}, invita a bob;
atacante (nkey registrado) publica RAW un frame Sig=nil, Sender="victim-...";
-> "SIG-NIL SPOOF CONFIRMED: receiver ACCEPTED an unsigned frame with forged Sender in a SignMsgs room"
```
Bug **preexistente** (no introducido por 0001/0004) pero relevante: en una room que exige firma, un frame sin firma debe **dropearse**, no aceptarse. En rooms `SignMsgs` sin cifrar (permitidas fuera de un bind público) cualquier peer con acceso al subject suplanta a cualquiera; en rooms E2E el atacante necesita ser miembro (tener K para cifrar), por lo que el riesgo es miembro-suplanta-a-miembro. **Fix:** `if SignMsgs { if f.Sig == nil { return }; verify(...) }`.
### N4 — Control plane TLS no forzado en bind público (Medio)
El guard de H2 cierra "TLS sin enforce" y "público sin enforce", pero **no** exige TLS en un bind público:
```
TestGap_PublicEnforceNoTLS:
validateBootConfig("0.0.0.0", AuthEnforce, "", "") == nil
-> "public bind + enforce + NO --tls-cert is ALLOWED -> control plane serves PLAINTEXT HTTP publicly"
```
Es decir, H5 está disponible pero no obligatorio: un operador que despliega público con `enforce` y olvida `--tls-cert` expone el control plane en HTTP plano (metadata en claro, el hallazgo H5 reaparece). **Fix:** el guard debe exigir `--tls-cert/--tls-key` cuando el bind no es loopback.
### H4 — Sin ACL por subject en NATS (Medio, residual conocido)
El fix `RequireEncryptedRooms` (prohibir cleartext en bind público) protege el **contenido**, no la **metadata** del data plane. Un registrado con conexión NATS raw y `Subscribe(">")` recibe todo:
```
TestReaudit_H4_WildcardMetadataLeak (room E2E ModeMatrix de alice; eve registrada, NO miembro):
eve '>' capturó 13 frames, subjects: [ room.e2e.confidential, $JS.API.STREAM.CREATE.UNIBUS_..., $JS.EVENT.ADVISORY.STREAM.CREATED..., ... ]
-> el subject real de la room y la actividad de JetStream se filtran; el payload sí es ciphertext E2E
```
El report del fix lo reconoce explícitamente como exposición residual. Se cierra de verdad con **NATS accounts/permissions por identidad** — encaja en el issue 0003 (descentralización), donde además se agrava (más nodos, más usuarios). Hasta entonces, el aislamiento entre rooms en el data plane depende del E2E para el contenido y queda nulo para la metadata.
### Pendientes del report 0004 no abordados por el fix 0004
- **H8** (operacional): la CA cuyo `ca.crt` está commiteado tiene su `ca.key` en el working tree del PC. Generar/custodiar en om.
- **H9** (bajo, se combina con N2): blobs sin cuota ni GC.
- **H10** (bajo): AEAD `chacha20poly1305` nonce 12 B aleatorio (irrelevante a volumen de chat).
- **H11** (bajo): la firma de owner (invite/rekey) no lleva nonce/ts propio; protegida sólo por el envelope `enforce`.
---
## Confirmaciones (resisten — verificado)
| Prueba | Resultado |
|---|---|
| Fuzz `frame.Unmarshal` (+ `SigningBytes`/`Marshal` del resultado) | 11 655 853 ejecuciones en 15 s, **0 panics/crashes**. Parser robusto a input malformado. |
| Suite completa en HEAD limpio | `go build ./...` + `go vet` + `go test ./pkg/... ./cmd/...` **verdes**. |
| H7 anti-replay autorizado | replay de identidad autorizada → `401 replayed nonce` (sigue intacto tras reordenar checks). |
| Cliente móvil (`mobile/unibus.go`) | migrado: `NewSession(...caPath)``client.Connect` (TLS+nkey+https forzado con caPath). |
| Rate-limit per-IP | ráfaga > burst desde 1 IP → `429` (funciona; ver N2 para su límite). |
---
## Recomendaciones priorizadas (antes de exponer público)
**Bloqueantes:**
1. **N1**`go get github.com/nats-io/nats-server/v2@v2.11.15` (o superior) + toolchain **go1.26.4**; re-correr `govulncheck` hasta 0 affected.
2. **N3** — en `processFrame`, dropear el frame sin firma en rooms `SignMsgs` (no saltar la verificación).
3. **N2** — acotar memoria agregada: límite global de conexiones concurrentes / bytes-en-vuelo, o streaming del blob a disco.
4. **N4** — el guard de arranque debe exigir `--tls-cert` en bind no-loopback.
**Recomendado:**
5. **H4** — planificar NATS accounts/permissions por identidad (cerrar la fuga de metadata del data plane); coordinar con issue 0003.
6. **H9** — cuota/GC de blobs (se solapa con N2). **H11** — nonce/ts en la firma de owner. **H8** — custodia de la CA.
**Para el cliente gateway (`playground/`):** corre su propio `membershipd` en `AuthOff` + `http://`. Es dev-local y está documentado, pero debe quedar claro que **no** se exponga en una interfaz pública.
---
## Nota de proceso (importante)
Durante esta re-auditoría, el **working tree del sub-repo estaba siendo modificado en paralelo por otro agente** (trabajo del issue 0003 — cluster NATS): en un punto `pkg/embeddednats/embeddednats.go` tenía cambios sin commitear que **no compilaban** (`applyClusterOpts` undefined, import `net/url` sin usar), y minutos después el conjunto de archivos modificados era otro distinto. Esto coincide con el patrón conocido "dos agentes sobre el mismo working tree se pisan" (memoria `multi-agent-git-race-same-repo`).
Para no auditar código a medias ni tocar el árbol del otro agente, esta auditoría se hizo sobre un **clon aislado del commit `618f6b6`** (master, fix 0004 cerrado, que compila y pasa la suite). Implicaciones:
- El **veredicto aplica al commit `618f6b6`**, no al working tree actual (que tiene WIP del 0003 y, al cierre de este report, otros 7 archivos modificados sin commitear).
- **Recomendación de proceso:** serializar el trabajo sobre `dataforge/unibus` o usar `git worktree`/clones por agente. Un build roto en el working tree compartido bloquea cualquier verificación. El master commiteado está sano; el árbol de trabajo no lo estaba.
---
## Gaps de esta re-auditoría (honestidad)
- **N1** se reporta a partir de `govulncheck` (traces "affected"); no se construyó un exploit por cada CVE — el bump de versión es la acción correcta independientemente del exploitability individual.
- **N2** se midió desde una sola máquina/IP (1.42 GB con 40 conexiones); el caso distribuido (multi-IP) es extrapolación directa del diseño del rate-limit (per-IP).
- No se auditó el WIP del issue 0003 (cluster/routes auth, jetstreamStore) porque está incompleto y no compila; queda para una auditoría dedicada cuando 0003 cierre (el report 0004 ya dejó las implicaciones de descentralización: anti-replay multi-nodo, auth de routes, fail-closed sobre KV).
- No se re-ejecutó `govulncheck` tras un bump hipotético (no se modificó `go.mod`, por la regla de no tocar producción).
- El clon usó `go1.26.x` del sistema; las 2 CVEs de stdlib reflejan ese toolchain.