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

15 KiB
Raw Blame History

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