29fe688b7a
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
218 lines
15 KiB
Markdown
218 lines
15 KiB
Markdown
# 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 (H1–H7, 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.
|