{
“tags”: [“terminal”, “agent”, “security”, “csrf”, “dns-rebinding”, “websocket”, “ed25519”, “snmp”, “fastapi”, “audit-suprema”, “dos”],
“sources”: [
{“type”: “commit”, “ref”: “2cd63e6e48ab58f3bcf5f3e8c67cecef0778ca09”, “last_seen”: “2026-09-01”},
{“type”: “code”, “ref”: “terminal/agent/core/local_token.py”, “last_seen”: “2026-09-01”},
{“type”: “code”, “ref”: “terminal/agent/routes/saas.py”, “last_seen”: “2026-09-01”},
{“type”: “code”, “ref”: “terminal/agent/routes/admin.py”, “last_seen”: “2026-09-01”},
{“type”: “code”, “ref”: “terminal/agent/main.py”, “last_seen”: “2026-09-01”},
{“type”: “code”, “ref”: “terminal/agent/core/pkg_updater.py”, “last_seen”: “2026-09-01”},
{“type”: “code”, “ref”: “terminal/agent/network/port_config/reader.py”, “last_seen”: “2026-09-01”},
{“type”: “code”, “ref”: “tests/agent/test_agent_t6_takeover_downgrade.py”, “last_seen”: “2026-09-01”},
{“type”: “doc”, “ref”: “CHANGELOG.md”, “last_seen”: “2026-09-01”},
{“type”: “doc”, “ref”: “supercontext/auditoria-suprema-2/PLAN_ARREGLOS.md”, “last_seen”: “2026-09-01”},
{“type”: “commit”, “ref”: “92c583f5f504787d98f033cb2ecb7aaebd07319d”, “last_seen”: “2026-09-01”},
{“type”: “code”, “ref”: “terminal/agent/network/config_backup.py”, “last_seen”: “2026-09-01”},
{“type”: “code”, “ref”: “tests/agent/test_agent_config_backup_techo.py”, “last_seen”: “2026-09-01”}
],
“related”: [“entity—terminal—service—local-token”, “entity—terminal—service—agent-auth-js”, “decision—20260831—tanda-5-auth-agente-backend”, “incident—20260831—auditoria-suprema-2-tanda1-monitoring-racks”, “incident—20260831—auditoria-suprema-2-tanda3-xss-signage-editor”, “feature—security—auditoria-suprema-2-tanda-1-core”, “concept—saas—multi-tenancy”, “decision—20260611—fase-3-auth-local-enforcement-on”, “decision—20260829—sa4-g2-command-smuggling-cerrado”],
“content”: ”## Cuándo\n\n01-09-2026 · commit 2cd63e6e (PR #483, v1.97.0 / Agent 2.26.0). Sexta tanda de la Auditoría Suprema 2, y los hallazgos calificados por el propio plan de arreglos como los más graves de toda la auditoría — el Agente local (.exe) va aparte del resto de tandas por ser un ciclo especial: compilar, firmar, publicar y esperar a que la flota se auto-actualice, no un deploy normal de PROD. Cierra los 12 ALTA de terminal que la Tanda 5 dejó pendientes ([[decision—20260831—tanda-5-auth-agente-backend]] cerró la mitad backend/SaaS del mismo dominio; esta tanda cierra la mitad Agent-facing).\n\n## Síntomas visibles\n\n5 hallazgos en el mismo commit:\n\n1. Toma de control completa del Agente desde cualquier página web (terminal/agent/core/local_token.py + routes/saas.py): /saas/* estaba exento ENTERO del gate de token local, con el argumento de que “su seguridad la da el JWT del SaaS” — pero auth._decode_jwt no verifica la firma. El único guard restante (_validate_saas_url) metía el Origin de quien llamaba SIEMPRE en la lista de orígenes permitidos, así que la comprobación real era “saas_url == quien llama”: una página en https://evil.com se auto-aprobaba pidiendo vincularse a https://evil.com. Vectores: POST simple sin preflight (parámetros en query string) y DNS-rebinding. Consumado, el Agente pasaba a obedecer al SaaS del atacante, con acceso a SSH, SFTP, backups y discovery sobre toda la red del cliente.\n2. /saas/status y /saas/profiles filtraban {agent_id, tenant_id} sin exigir token.\n3. /ws/debug (routes/admin.py) sin ninguna autenticación: el middleware HTTP de local_token no cubre WebSockets, y estos tampoco pasan por CORS — cualquier página abría ws://127.0.0.1:5050/ws/debug y leía el log del Agente en vivo (IPs, hostnames, trazas de sesión).\n4. Downgrade forzado: la firma Ed25519 de un paquete de actualización cubre los bytes del zip, pero no el campo version del .meta.json que lo acompaña — un zip viejo, legítimamente firmado, con un manifiesto que mintiera una versión alta pasaba la verificación y el Agente “actualizaba” a código antiguo con vulnerabilidades ya conocidas.\n5. PortConfigReader creaba un SnmpEngine propio en cada lectura de puertos y nunca lo cerraba (fuga de sockets UDP).\n\n## Causa raíz\n\nDos patrones distintos:\n\n- Guard tautológico: el fix anterior de este mismo hallazgo (Agent 2.6.0, _validate_saas_url “exige match con Origin”, registrado en el Atlas de terminal como Rectificado) comparaba el saas_url candidato contra un conjunto que SIEMPRE incluía el Origin de la propia petición. Eso no es una comprobación de identidad: es aceptar cualquier valor que la petición diga de sí misma. Sumado a que /saas/* completo vivía fuera del gate de token (razonado como “innecesario, ya lleva JWT”) y a que ese JWT se decodifica sin verificar firma, las tres capas de defensa previstas fallaban a la vez.\n- Firma que no cubre todo lo que decide el comportamiento: el esquema de firma Ed25519 protege la integridad del paquete, pero el número de versión —que es el dato que decide si el Agente considera el paquete una actualización válida— viajaba en un campo separado, fuera de lo firmado.\n\n## Fix aplicado\n\nCommit 2cd63e6e48ab58f3bcf5f3e8c67cecef0778ca09 (PR #483):\n\n- /saas/* sale de EXEMPT_PATHS; pasa por el gate de token local como cualquier otra ruta. Excepción única y acotada: saas_bootstrap_origin_ok() (nueva, en local_token.py) deja pasar sin token solo si el Origin del handshake es el SaaS ya vinculado o un origen de desarrollo local — preserva el botón “Reauth” (que llama a /saas/setup justo cuando el token local aún no está depositado en el SaaS) sin reabrir el hueco, porque el Origin lo escribe el navegador y no es falsificable desde JS.\n- saas_url_allowed() (nueva, pura, sin FastAPI, vive en local_token.py para poder testearse en el contenedor del SaaS que no tiene fastapi) sustituye a _validate_saas_url: con un SaaS ya vinculado, el Origin deja de ser un permiso — solo se admite re-vincular al MISMO SaaS. El Origin solo cuenta como permiso durante la PRIMERA vinculación (cuando no hay nada que suplantar todavía).\n- /ws/debug (routes/admin.py) valida el Origin del handshake WebSocket contra los orígenes permitidos del Agente, o el token local si no hay Origin (clientes no-navegador); si no matchea, cierra la conexión con el código WS 4401.\n- Downgrade: _version_inside_zip() lee version.py DENTRO del zip firmado y lo compara contra el version declarado en el .meta.json. Implementado dos veces a propósito: en main.py (el bootstrap, que solo se actualiza recompilando el .exe) y en pkg_updater.py (que viaja DENTRO del paquete de código y por tanto llega a la flota ya instalada vía auto-update, sin esperar a que nadie recompile).\n- PortConfigReader reutiliza el SnmpEngine compartido del Sentinel (network.snmp.create_engine()) en vez de crear uno propio — cerrar el propio no era opción válida: en pysnmp 7.x los engines del mismo bucle comparten transporte UDP y cerrar uno corrompe a los demás.\n- 11 tests nuevos (tests/agent/test_agent_t6_takeover_downgrade.py), incluido uno que firma de verdad un paquete con una clave Ed25519 de prueba para probar el downgrade (con firma inválida la verificación corta antes y no probaría nada real). Se actualiza el test que fijaba el contrato antiguo de /saas/* exento.\n\n## Lecciones\n\n- Un guard que compara “lo que declara la petición” contra “lo que la propia petición dice de sí misma” no es un guard — es tautológico. El fix de Agent 2.6.0 tenía exactamente esa forma y quedó registrado como Rectificado en el Atlas de arquitectura; nadie repitió el ataque contra el código vivo hasta esta auditoría, solo se confió en el registro de que “ya se había arreglado”.\n- Cuando una firma criptográfica protege el CONTENIDO de un artefacto pero un campo de metadata que viaja junto (aquí, version) queda fuera de la firma, ese campo sigue siendo superficie de ataque aunque el resto esté correctamente firmado — hay que preguntar explícitamente “¿qué decide el comportamiento aquí, y está eso dentro de lo firmado?”.\n- Una excepción de auth para un flujo de emergencia (reauth cuando el token local aún no está depositado) necesita su propia guarda estrecha y no falsificable (Origin del navegador); eximir la ruta ENTERA porque “ya lleva su propio JWT” fue el error original — un JWT sin verificación de firma no es una guarda.\n\n## Preventivos futuros\n\n- Ciclo especial, no cerrado del todo con este commit: AGENT_VERSION sube a 2.26.0 pero el fix del bootstrap (main.py) solo alcanza a los Agentes que recompilen y publiquen el .exe nuevo; el de pkg_updater.py sí llega a la flota ya instalada por auto-update. Queda pendiente compilar y publicar el .exe 2.26.0 y verificar en pantalla que la flota reconecta tras el auto-update, como se hizo en la Tanda 5.\n- Actualizar la ficha del Atlas de terminal (supercontext/atlas-arquitectura/fichas/terminal.md), que sigue marcando este hallazgo como “Rectificado (2.6.0)” sin el matiz de que ese fix era tautológico — para que una lectura futura no repita la misma confianza infundada.\n- Los 174 MEDIA y 83 BAJA restantes de la Auditoría Suprema 2 quedan para la segunda pasada (PLAN_ARREGLOS.md); ninguno de los de terminal es de esta familia (toma de control/downgrade).\n\n## Extensión — v1.97.1 (techo de tamaño en la lectura SSH del backup, #484, 2026-09-01)\n\nFleco arrastrado de la Tanda 4 (el hallazgo original es de esa auditoría — [[decision—20260829—sa4-g2-command-smuggling-cerrado]], que valida el comando SALIENTE hacia el equipo) pero entregado junto a esta Tanda 6 por ser fichero del Agente .exe: entra antes de compilar para viajar en la misma publicación de Agent 2.26.x.\n\nEl hallazgo es el lado que RECIBE la respuesta del equipo: _read_until_idle (terminal/agent/network/config_backup.py), la función que lee por SSH la salida de show running-config en el propio Agente local, solo cortaba por tiempo (_MAX_TOTAL_S, el presupuesto) o por silencio (_IDLE_GAP_S) — nunca por tamaño. Un equipo averiado, o suplantado en la LAN del cliente, que emitiera sin parar llenaba la memoria del Agente —que corre en la máquina del cliente, no en el servidor de CreaRack— durante todo el presupuesto de tiempo.\n\nFix (commit 92c583f5f504787d98f033cb2ecb7aaebd07319d, PR #484, v1.97.1 / Agent 2.26.1): nuevo techo _MAX_CONFIG_BYTES (16 MiB, dos órdenes de magnitud por encima de un show running-config grande) que corta la lectura y avisa en el log aunque el equipo siga emitiendo. De paso, la acumulación pasa de data += chunk (cuadrática) a \"\".join(chunks). 3 tests nuevos en tests/agent/test_agent_config_backup_techo.py: el equipo que no para (cortado por tamaño, no por presupuesto agotado), una configuración normal servida entera, y el techo verificado como holgado frente a un caso real.\n\n## Véase también\n\n- [[entity—terminal—service—local-token]]\n- [[entity—terminal—service—agent-auth-js]]\n- [[decision—20260831—tanda-5-auth-agente-backend]]\n- [[incident—20260831—auditoria-suprema-2-tanda1-monitoring-racks]]\n- [[incident—20260831—auditoria-suprema-2-tanda3-xss-signage-editor]]\n- [[feature—security—auditoria-suprema-2-tanda-1-core]]\n- [[concept—saas—multi-tenancy]]\n- [[decision—20260611—fase-3-auth-local-enforcement-on]]\n- [[decision—20260829—sa4-g2-command-smuggling-cerrado]] — el hallazgo original de Tanda 4 (comando saliente); esta extensión cubre el lado de la respuesta entrante\n”
}