# 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.