Volver a la wiki

ADR · Autenticación local cross-origin del Local Agent (Auditoría Suprema · terminal · Raíz 3)

Contexto

La Auditoría Suprema del dominio terminal (s122, ~18k LOC, el mayor del proyecto) confirmó 6 raíces. Cinco ya están rectificadas (backend raíces 1+2 en s123, PRs #96/#97; Agent raíces 4/5/6 en s124, Tanda B1, PR #98 / Agent 2.0.22). Queda la Raíz 3, la más prolífica del dominio: la API HTTP local del Agent (FastAPI, localhost:5050) no tiene autenticación y el CORS está abierto de par en par.

Hallazgo (terminal-sa4.json, 12 ALTA confirmados, 3/3 votos):

Impacto: endpoints alcanzables sin credenciales que ejecutan comandos en dispositivos (/cluster/execute, /cluster/script, /network/ssh-show), abren SSH/SFTP (/sftp/*), escanean la red interna del cliente (/check, /network/*), despliegan signage y hasta desinstalan el Agent (/admin/uninstall). La denylist de handle_ai_remediate (main.py L414-436) es teatro de seguridad (prefix-match evadible y solo cubre el remediador IA, no los endpoints HTTP).

Decisión

Implementar un esquema de token de autenticación local mediado por el SaaS, aplicado como dependencia global de FastAPI en el Agent, + cierre del CORS al origin del SaaS + guard de Origin/Host anti DNS-rebinding.

El secreto lo crea el Agent, lo custodia el SaaS, y el SaaS solo lo entrega a una sesión web autenticada del tenant correcto. Una web ajena no está logueada en el backend del tenant → nunca obtiene el token; y con el CORS cerrado ni siquiera puede leer las respuestas del Agent.

Naturaleza del token (decidido)

Token opaco aleatorio (secrets.token_urlsafe(32)), no JWT firmado. Validación = comparación en tiempo constante (hmac.compare_digest), sin criptografía, sin claves que distribuir en el .exe, sin relojes/caducidad que cuadrar (se rota al re-emparejar). Es por-agente, nunca una clave global embebida en el binario distribuido.

Flujo

Emparejamiento (una vez · reusa el canal que YA existe):

  1. El Agent genera local_session_token = secrets.token_urlsafe(32) y lo guarda cifrado con DPAPI (igual que credentials.enc en core/auth.py).
  2. Lo registra en el SaaS por el canal autenticado existente (el WebSocket Agent↔SaaS / POST /saas/setup), quedando ligado al agent_id + organization (tenant) en AgentInstance.

El canal de confianza frontend→Agent ya existe: hoy POST localhost:5050/saas/setup (routes/saas.py L112-162) recibe del frontend logueado el JWT del usuario (token+refresh_token) y el Agent lo persiste cifrado. No hay que inventar el emparejamiento: ya está montado.

Uso (cada interacción del frontend con el Agent): 3. El navegador (sesión Django válida del tenant) pide el token a un endpoint nuevo del backend: GET /api/agent/local-token → responde solo con sesión autenticada y si el agente pertenece a ese tenant (devuelve 403 en otro caso). 4. El frontend lo envía en Authorization: Bearer <local_session_token> en cada fetch a localhost:5050. 5. El Agent valida (comparación en tiempo constante) + comprueba que el Origin es https://*.crearack.com (guard anti DNS-rebinding).

Custodia del secreto en el SaaS

Campo nuevo en AgentInstance (terminal/models.py:27), p.ej. local_token_enc, cifrado con CREDENTIAL_ENCRYPTION_KEY (la clave dedicada del Plan Hardening E, ya en PROD). El SaaS lo guarda recuperable (no solo hash) porque debe entregarlo en claro al frontend autenticado; el cifrado en reposo cubre el caso de fuga de BD.

Aplicación en el Agent

app = FastAPI(dependencies=[Depends(require_local_token)]) → cubre los 11 routers de un plumazo, salvo /health (latido de liveness, único endpoint no autenticado). Esto cierra los 12 ALTA simultáneamente, porque todos comparten la misma raíz “alcanzable sin credenciales”.

Plan de despliegue por fases (orden crítico)

El Agent no está en CI (Windows-only) → gate = smoke manual de Edu. El orden evita dejar el terminal inservible durante la transición:

  1. Fase 1 — Backend SaaS (PR normal, inerte): migración con local_token_enc en AgentInstance + endpoint GET /api/agent/local-token (sesión autenticada + scope de tenant) + recepción/custodia del secreto que registra el Agent. No afecta a nadie hasta la Fase 2.
  2. Fase 2 — Frontend manda el token (deploy): centralizar el envío del Authorization en static/js/modules/agent_connector.js (un único punto), en lugar de tocar los ~35 ficheros JS que llaman a :5050. Inocuo para .exe viejos (un Agent viejo ignora el header). Dejar propagar (caché del navegador).
  3. Fase 3 — Agent que EXIGE el token (Agent 2.0.25+, rebuild + release): cuando el frontend ya manda el token en PROD, publicar el .exe que lo exige + cierra CORS a *.crearack.com + check de Origin. build_agent.bat a mano en el PC de Edu → CreaRackAgent.exe → release lo publica el agente.

Riesgo residual: frontend viejo cacheado + Agent nuevo. Como el frontend lo sirve el SaaS y se refresca al recargar, la ventana es corta; mantener margen temporal entre Fase 2 y Fase 3.

Carve-out (cambian contrato, fuera de esta Raíz pero del mismo dominio)

Pendientes que tocan contrato y se tratan aparte (no son la Raíz 3 pura): agent_self_reauth (acuña tokens solo con agent_id+tenant_id sin prueba de posesión — SA2), códigos de la ingesta de métricas, JWT en query-param del WS (get_websocket_url en core/auth.py L291-295), token en download_url.

Hardening backend diferido (PR normal, no urge)

UniqueConstraint parcial de Primary + carrera de consumers._register_and_assign_role (requieren migración + dedup de datos previo).

Alternativas descartadas

Consecuencias

Estado

Aceptada (10-06-2026). Esquema y naturaleza del token decididos por Edu. Pendiente: implementación Fase 1 tras luz verde sobre este ADR.

Véase también

Subir