Servicio: Autenticación local del Agent (Bearer token)
Descripción general
Servicio de validación de tokens Bearer para la API HTTP local del Agent (puerto 5050) y WebSocket de la consola. Implementa el mecanismo de llave secreta compartida entre el SaaS y el Agent, habilitando multi-tenancy segura en el dominio terminal. Desde el Agente 2.29.0 (v1.159.0) esta protección se complementa con guardas sobre a QUÉ SaaS se vincula el Agente y a QUÉ destinos se conecta en nombre del navegador (ver sección final).
Módulos principales:
terminal/agent/core/local_token.py— Lógica de depósito, validación y renovación de tokens, y (desde 2.29.0) qué orígenes SaaS puede aceptar el Agente.terminal/agent/core/net_guard.py— Guarda de destino saliente: valida y pinnea la IP a la que el Agente conecta (ver sección final).static/js/modules/agent_auth.js— Interceptor HTTP del frontend para inyectar Bearer en llamadas locales.tests/agent/test_agent_local_auth.py— Suite de validación (depósito, verificación, enforcement).
Funciones públicas
deposit_local_token(agent_id: str, saas_url: str) -> str
Propósito: El Agent (.exe) deposita su token en el SaaS durante el registro/vinculación.
Firma:
def deposit_local_token(agent_id: str, saas_url: str) -> str:
"""
POST /api/agent/local-token/deposit (endpoint SaaS)
Args:
agent_id: Identificador único del Agent (ej: "a1-prod-05")
saas_url: Raíz del SaaS destino (ej: "https://crearack.com")
Returns:
Token aleatorio de 64 caracteres (hexadecimal).
Lógica:
1. Agent genera token local (secrets.token_hex(32)).
2. Agent lo custodia en ~/.crearack/local_token (mode 0600, solo lectura del Agent).
3. Agent POST al SaaS → SaaS lo almacena asociado al (agent_id, tenant).
4. SaaS responde 201 + token confirmado.
"""
Flujo:
- Agent arranca, se vincula a un SaaS (connector._register_local_token).
- Agent genera token local (criptográficamente seguro).
- Agent lo almacena localmente y lo envía al SaaS.
- SaaS lo custodia en una tabla (
agent_local_tokensen PostgreSQL).
Fuente de datos: Inicializado en connector.py durante _register_local_token().
verify_local_token(bearer_token: str) -> bool
Propósito: Validar que el Bearer recibido en una llamada HTTP coincide con el token depositado.
Firma:
def verify_local_token(bearer_token: str) -> bool:
"""
Compara el Bearer contra el token custodiado localmente.
Args:
bearer_token: Token extraído del header Authorization: Bearer <token>
Returns:
True si coincide (timing-safe comparison), False otherwise.
Lógica:
1. Lee token local desde ~/.crearack/local_token.
2. Compara con hmac.compare_digest() (resistente a timing attacks).
3. Si no existe archivo local → devuelve False.
"""
Uso: Decorador @auth_required() en endpoints de la API local.
auth_required(auth: AgentAuth | None) -> bool
Propósito: Determinar si una llamada HTTP específica debe exigir token Bearer.
Firma:
def auth_required(auth: AgentAuth | None) -> bool:
"""
Resuelve si el endpoint actual exige autenticación.
Args:
auth: Contexto de autenticación del Agent (saas_url, agent_id, etc.)
Si es None → pre-activación (no vinculado aún) → no exige.
Returns:
True si exige Bearer, False otherwise.
Lógica:
1. Si AGENT_LOCAL_AUTH_ENFORCE=0 → siempre False (escape hatch).
2. Si auth es None o no tiene saas_url/agent_id → False (pre-activación).
3. Si auth vinculado (saas_url + agent_id) → True (enforcement activo).
4. Respeta EXEMPT_PATHS (bootstrap, /info, /health) → siempre False.
"""
Variables de control:
LOCAL_AUTH_ENFORCE(env:AGENT_LOCAL_AUTH_ENFORCE, default"1"desde 2.1.2).EXEMPT_PATHS(tupla de prefijos no autenticados, ej:/bootstrap,/health).
extract_bearer(headers: dict) -> str | None
Propósito: Extraer token Bearer del header Authorization.
Firma:
def extract_bearer(headers: dict) -> str | None:
"""
Busca header Authorization: Bearer <token>
Args:
headers: Headers HTTP (case-insensitive).
Returns:
Token sin el prefijo "Bearer ", o None si no existe.
Manejo de casos**:
- "Authorization: Bearer abc123" → "abc123"
- "authorization: Bearer xyz " → "xyz" (sin espacios)
- "Bearer abc" (sin colon) → None
- "Basic ..." → None
"""
Interceptor del frontend (agent_auth.js)
getAgentLocalToken()
Propósito: Obtener el token Bearer del SaaS e inyectarlo en futuras llamadas HTTP al Agent.
Flujo:
async function getAgentLocalToken() {
const infoResp = await fetch("http://127.0.0.1:5050/info");
const info = await infoResp.json();
const agentId = info.saas.agent_id;
const tokenResp = await fetch("https://crearack.com/api/agent/local-token", { method: "GET", credentials: "include" });
const { token } = await tokenResp.json();
_injectedToken = token;
return token;
}
function interceptFetch(origFetch) {
return function(url, opts) {
if (url.startsWith("http://127.0.0.1:5050")) {
opts.headers = opts.headers || {};
opts.headers["Authorization"] = `Bearer ${_injectedToken}`;
}
return origFetch(url, opts);
};
}
Bug corregido en 2.1.2: info.saas_info.agent_id (clave inexistente) → info.saas.agent_id.
v1.159.0 (mega-auditoría T22, B-18/B-20): openInNewWindow (usada por los botones Debug/Tools/Dashboard del Agente en el Observatory) añade el token local en el fragmento de la URL (#token=...), solo para destinos 127.0.0.1:5050 — así la consola en vivo y la tabla de traps siguen recibiendo el Bearer aunque se abran en una ventana nueva. Además abre la ventana en el mismo clic (evita el bloqueo de pop-ups) y navega a la página del Agente en cuanto fetchToken resuelve, con un timeout de 5 s si no responde. agent_auth.js?v=3, base.js?v=15.
Ciclo de vida del token
Agent arranca + se vincula a SaaS (ej: crearack.com/setup)
→ deposit_local_token(): genera token, lo guarda en ~/.crearack/, lo envía al SaaS
→ SaaS lo custodia en DB (agent_local_tokens: agent_id, token, tenant, created_at)
→ Navegador (tenant legítimo) abre dashboard o consola SSH
→ Frontend solicita token: GET /api/agent/local-token (sesión + fleet:view)
→ SaaS valida sesión + permisos, entrega el token (200 + {"token": "abc..."})
→ agent_auth.js inyecta Bearer en llamadas HTTP locales
→ Agent valida Bearer con verify_local_token() → procesa o rechaza 401
Configuración
| Env var | Defecto | Descripción |
|---|---|---|
AGENT_LOCAL_AUTH_ENFORCE | "1" (ON) | Activa/desactiva exigencia de Bearer (desde 2.1.2). 0 = escape hatch. |
AGENT_LOCAL_TOKEN_PATH | ~/.crearack/local_token | Ruta donde el Agent custodia el token localmente. |
AGENT_LOCAL_TOKEN_TTL | 86400 (24h) | TTL del token en el SaaS (renovación automática en login). |
AGENT_EXTRA_SAAS_ORIGINS (2.29.0) | vacío | Dominios propios adicionales admitidos en la primera vinculación, además de los oficiales (ver abajo). |
CORS vivo (auditoría MEDIA #13, v1.116.0)
allowed_origins_for se evaluaba una sola vez, al instalar el middleware en el arranque de install_local_security. Un Agente recién instalado arranca sin vincular, así que la lista de orígenes quedaba fijada a ["*"] — y seguía siéndolo DESPUÉS de emparejarse, hasta que alguien reiniciara el servicio.
LiveOriginsCORSMiddleware (subclase de CORSMiddleware de Starlette) resuelve esto re-derivando los orígenes en cada petición HTTP: si allowed_origins_for(get_auth_manager(), port) cambia respecto a la última vez, vuelve a ejecutar el __init__ de la clase base con la lista nueva. Desde que hay vinculación, allowed_origins_for nunca vuelve a devolver ["*"].
Agente 2.29.0 — orígenes SaaS y destinos salientes (mega-auditoría ronda 4, 25-09-2026, PR #607)
Hasta 2.29.0, un Agente sin vincular aceptaba cualquier SaaS como destino de primera vinculación, y una vez vinculado, obedecía instrucciones de descarga/proxy sin comprobar que vinieran realmente del SaaS emparejado. El binario tampoco distinguía “esto es un origen de desarrollo” de “esto es producción” — los orígenes de desarrollo viajaban dentro del .exe que llega al cliente.
B-17/B-18/B-19/B-20/B-54 — la API local deja de obedecer a webs y programas ajenos:
- Primera vinculación restringida: solo acepta
crearack.com,stage.crearack.comy (para el propio equipo, vía STAGE por NetBird)http://crearack-staging.netbird.cloud:8000. Un despliegue con dominio propio necesitaAGENT_EXTRA_SAAS_ORIGINSexplícito — ya no vale cualquier origen que se presente primero. is_saas_download_url:/signage/deploy(descarga de contenido a la pantalla) exige que el origen de la URL de descarga case esquema, host y puerto exactos con el SaaS ya vinculado — antes bastaba con que “pareciera” del dominio correcto.- Orígenes de desarrollo fuera del binario: los orígenes usados en desarrollo local ya no viajan en el
.exede producción. - Rescate de
/saas/*acotado: la vía de “re-emparejar” (/saas/setup,/saas/configure) solo responde cuando el Agente no tiene conexión SaaS viva — con conexión activa, no se puede re-secuestrar la vinculación desde el navegador. /ws/debugcon token: la consola de depuración en vivo exige el mismo Bearer que el resto de la API local (antes era una de las rutas exentas)./traps/recentcon token yHostvalidado: además del Bearer, comprueba la cabeceraHostde la petición.- Guarda de destinos + IP pinneada:
ping,scan,discover,/network/ping-icmp,/network/banner,/signage/configurey el dispositivo destino de/signage/deployresuelven y validan el host connet_guard(ver abajo) antes de conectar, y conectan a esa IP ya validada — no a lo que un DNS controlado por el atacante decida devolver en el momento de la conexión real (mismo patrón anti-TOCTOU quemonitoring.services.net_guarden el lado SaaS). /signage/upload: rechaza destinos loopback, link-local y de metadatos de nube (400) y sube a la IP ya validada, no a la que se resuelva de nuevo al conectar.
terminal/agent/core/net_guard.py (módulo nuevo del Agente, desde 05-09-2026, ampliado en esta ronda): expone resolve_allowed_target(host, saas_url) / is_allowed_target(host, saas_url) — la contraparte, en el lado del Agente, del guard SSRF que monitoring.services.net_guard aplica en el lado del SaaS: valida que un destino de red (host que el navegador o el SaaS piden alcanzar) no sea loopback, link-local o un rango privado inesperado antes de que el Agente abra la conexión saliente.
Tests que cambian por decisión de Edu (documentado en CHANGELOG): tres tests existentes de tests/agent/ ajustan sus expectativas a la nueva restricción de orígenes. Test nuevo: tests/agent/test_mega25_T22_api_local.py (504 líneas) — cobertura de las guardas de origen y destino descritas arriba.
Versión del Agente: 2.29.0 (AGENT_VERSION sin subir en este PR — pendiente de versionado explícito).
Seguridad
Amenazas mitigadas (Raíz 3 de la Auditoría Suprema + mega-auditoría ronda 4)
| Amenaza | Mitigación |
|---|---|
DNS-rebinding: Navegador abre http://127.0.0.1:5050 y envía comandos | Bearer válido solo en sesión del tenant. |
| CSRF: Formulario en web maliciosa + sesión del navegador activa | Bearer no es cookie; no se roba con CSRF. |
| Navegador comprometido: JavaScript malicioso en pestaña ajena | Sin token válido del SaaS, rechazado 401. |
| Interceptión local: Atacante en LAN local observa tráfico | Token es aleatorio; además HTTPS en el SaaS. |
| Vinculación a un SaaS ajeno (2.29.0) | Primera vinculación restringida a dominios oficiales + AGENT_EXTRA_SAAS_ORIGINS. |
| Instrucción de descarga/proxy de un origen ajeno (2.29.0) | is_saas_download_url exige esquema+host+puerto exactos del SaaS vinculado. |
| SSRF / DNS-rebinding en conexiones salientes del Agente (2.29.0) | net_guard valida y pinnea la IP antes de conectar (ping, scan, discover, signage). |
Protecciones implementadas
- ✅ Token de 64 caracteres hexadecimales (256 bits de entropía).
- ✅ Comparación timing-safe con
hmac.compare_digest()(previene timing attacks). - ✅ Almacenamiento local con permisos restrictivos (mode 0600).
- ✅ EXEMPT_PATHS para bootstrap (antes de tener token).
- ✅ Multi-tenancy: SaaS valida que solo el tenant propietario puede obtener el token.
- ✅ (2.29.0) Vinculación y descargas acotadas al SaaS oficial; destinos salientes pinneados por IP.
Tests
test_deposit_local_token(tmp_path, monkeypatch)
token = local_token.deposit_local_token(agent_id="test-agent", saas_url="https://test.crearack.com")
assert len(token) == 64
assert local_token.verify_local_token(token) is True
test_verify_token(tmp_path, monkeypatch)
token = local_token.deposit_local_token(...)
assert local_token.verify_local_token(token) is True
assert local_token.verify_local_token("wrong_token") is False
assert local_token.verify_local_token("") is False
test_auth_required_on_by_default()
assert local_token.LOCAL_AUTH_ENFORCE is True
assert local_token.auth_required(_FakeAuth(saas_url="...", agent_id="a1")) is True
assert local_token.auth_required(_FakeAuth()) is False
test_auth_required_escape_hatch(monkeypatch)
monkeypatch.setattr(local_token, "LOCAL_AUTH_ENFORCE", False)
assert local_token.auth_required(_FakeAuth(saas_url="...", agent_id="a1")) is False
v1.159.0 — tests/agent/test_mega25_T22_api_local.py
Cobertura de la restricción de orígenes de vinculación, is_saas_download_url, el rescate de /saas/* solo sin conexión viva, /ws/debug y /traps/recent con token, y las guardas de destino de net_guard en ping/scan/discover/signage.
Véase también
- [[decision—20260611—fase-3-auth-local-enforcement-on]]
- [[concept—saas—multi-tenancy]]
- [[entity—terminal—model—agentinstance]]
- [[concept—security—bearer-token-validation]]
- [[runbook—agent—bearer-token-troubleshooting]]