Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
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-repodataforge/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
/tmpdel commit618f6b6(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ímeramembershipdreal enenforce+ TLS de control plane y de datos (puertos altos, DB en/tmp, certs generados condeploy/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 av2.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.crtestá commiteado tiene suca.keyen el working tree del PC. Generar/custodiar en om. - H9 (bajo, se combina con N2): blobs sin cuota ni GC.
- H10 (bajo): AEAD
chacha20poly1305nonce 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:
- N1 —
go get github.com/nats-io/nats-server/v2@v2.11.15(o superior) + toolchain go1.26.4; re-corrergovulncheckhasta 0 affected. - N3 — en
processFrame, dropear el frame sin firma en roomsSignMsgs(no saltar la verificación). - N2 — acotar memoria agregada: límite global de conexiones concurrentes / bytes-en-vuelo, o streaming del blob a disco.
- N4 — el guard de arranque debe exigir
--tls-certen 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/unibuso usargit 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ó
govulnchecktras un bump hipotético (no se modificógo.mod, por la regla de no tocar producción). - El clon usó
go1.26.xdel sistema; las 2 CVEs de stdlib reflejan ese toolchain.