Informe de Auditoría de Seguridad — Arc OS

Fecha: 2026-04-23 Auditor: Sentinel (Super DevOps / Auditor de Seguridad) Fase del proyecto: 40.18 Alcance: Backend (Bun :19210), Nginx (:18888/:443), NotebookLM (:19213), Vault, Multi-tenancy


Resumen Ejecutivo

Se realizó una auditoría de seguridad del sistema Arc OS. Se encontraron 3 vulnerabilidades críticas de multi-tenancy que permiten a un usuario autenticado acceder a los recursos de proyectos ajenos (logs, terminal, wiki, issues). Actualmente el proyecto no almacena datos confidenciales de múltiples clientes, por lo que el riesgo de explotación es bajo, pero arquitectónicamente el sistema no está listo para producción multi-usuario.

Veredicto: AMARILLO — parchear C1-C3 inmediatamente antes de invitar a usuarios reales fuera del CEO.


🔴 VULNERABILIDADES CRÍTICAS (3)

CVE-C1 — Streams SSE sin verificación de owner_id

Severidad: Alta CWE: CWE-285 (Autorización Incorrecta) Archivos:

Descripción: La función routeSseRequest(pathname, query, registry) no recibe chatId y no llama a canAccessProject. Solo se verifica:

  1. JWT a través de crmAuthMiddleware
  2. isValidProjectName — solo para evitar path traversal

Explotación:

# El Usuario A obtuvo su token a través de /api/auth/login
# Conoce que existe el proyecto "victim-project" (desde la DB o por adivinación)
curl -N "https://arc-os.co/api/sse/logs/victim-project?token=$MY_TOKEN"
# → recibe el stream en tiempo real de logs JSONL del proyecto ajeno
# Lo mismo aplica para /api/sse/consultant/:name

Parche recomendado:

// shared/crm-routes.ts:2613
export function routeSseRequest(
  pathname: string,
  query: URLSearchParams,
  registry: Registry,
  chatId: string | null,  // ← añadir
): Response | null {
  const logsMatch = pathname.match(/^\/api\/sse\/logs\/([^/]+)$/);
  if (logsMatch) {
    const name = decodeURIComponent(logsMatch[1]);
    if (!isValidProjectName(name)) { ... }
    if (!canAccessProject(registry, chatId, name)) {
      return Response.json({ error: "Forbidden" }, { status: 403 });
    }
    return handleSseLogs(name, registry, query);
  }
  // lo mismo para /api/sse/consultant/:name
}

// master-bot/api-server.ts:406
const chatId = extractChatId(req);  // exportar desde crm-routes
const sseResponse = routeSseRequest(url.pathname, url.searchParams, ctx.registry, chatId);

CVE-C2 — Terminal WebSocket sin owner_id

Severidad: Crítica CWE: CWE-285 Archivo: master-bot/api-server.ts:250-283

Descripción: /ws/terminal/:name verifica JWT + isValidProjectName, pero no verifica la propiedad. Cualquier usuario autenticado obtiene acceso a la sesión tmux del proyecto ajeno. En modo ?mode=interactive esto es un shell interactivo.

El comentario en :270 ya lo reconoce:

const interactive = url.searchParams.get("mode") === "interactive";
// TODO: [debt-7] Gate behind admin-scoped token, not just query param

Explotación:

ws://arc-os.co/ws/terminal/victim-project?token=MY_TOKEN&mode=interactive
→ sesión de shell interactiva en la sesión tmux del proyecto ajeno
→ lectura/escritura completa del sistema de archivos del VPS como root

Parche recomendado:

// master-bot/api-server.ts:258
const projectName = decodeURIComponent(wsMatch[1]);
if (!isValidProjectName(projectName)) { ... }

// AÑADIR:
const chatId = verifyToken(token).chatId;
if (!canAccessProject(ctx.registry, chatId, projectName)) {
  return Response.json({ error: "Forbidden" }, { status: 403 });
}

// Modo interactivo — solo admin
if (interactive) {
  const user = userQueries.findById(chatId);
  const isCeo = String(ctx.registry.master.ceo_chat_id) === chatId;
  if (!isCeo && user?.role !== "admin") {
    return Response.json({ error: "Admin required" }, { status: 403 });
  }
}

CVE-C3 — /api/cli/* + /api/mcp/* sin owner_id (12+ endpoints)

Severidad: Alta CWE: CWE-285 Archivo: master-bot/api-server.ts:913-1084

Descripción: Todo el bloque /api/cli/* y /api/mcp/* solo verifica JWT (crmAuthMiddleware) + isValidProjectName. Sin canAccessProject. Endpoints afectados:

Endpoint Método Consecuencia
/api/cli/init/:project/:mode GET Obtener CLAUDE.md del proyecto ajeno
/api/cli/chat-log/:project POST Insertar mensajes en el chat ajeno
/api/mcp/skills/:project POST/GET Modificar skills del proyecto ajeno
/api/mcp/report/:project POST Enviar informe como si fuera del proyecto ajeno
/api/mcp/learnings/:project GET Leer learnings.md ajeno
/api/mcp/issues/:project POST/GET CRUD de issues del proyecto ajeno
/api/mcp/issues/:project/:id PUT Modificar issues ajenos
/api/mcp/issues/:project/:id/log POST Escribir en el activity trail
/api/mcp/wiki/:project PUT Sobreescribir la wiki del proyecto ajeno
/api/mcp/roadmap/:project GET/PUT Modificar el roadmap

Parche recomendado:

// master-bot/api-server.ts:914 (justo después del bloque if)
if (url.pathname.startsWith("/api/cli/") || url.pathname.startsWith("/api/mcp/")) {
  const preflight = handleCorsPreflightIfNeeded(req);
  if (preflight) return preflight;
  const denied = crmAuthMiddleware(req);
  if (denied) { ... }

  // AÑADIR ESTE BLOQUE:
  const projectMatch = url.pathname.match(/^\/api\/(cli|mcp)\/[^/]+\/([^/]+)/);
  if (projectMatch) {
    const project = decodeURIComponent(projectMatch[2]);
    if (isValidProjectName(project)) {
      const chatId = extractChatId(req);
      if (!canAccessProject(ctx.registry, chatId, project)) {
        const headers = corsHeaders(req.headers.get("Origin") || undefined);
        return Response.json({ error: "Forbidden" }, { status: 403, headers });
      }
    }
  }

  // ... router existente ...
}

⚠️ Atención: las rutas de descarga (/api/cli/download/...) y device-code (/api/cli/device/*) NO TIENEN proyecto en la URL — no deben ser bloqueadas. Verificar el orden de condiciones.


🟡 VULNERABILIDADES SERIAS (3)

CVE-S1 — Bun.serve escucha en 0.0.0.0:19210

Severidad: Media (defensa en profundidad) CWE: CWE-668 (Exposición de Recurso a Esfera Incorrecta) Archivo: master-bot/api-server.ts:159-161

Descripción:

const server = Bun.serve({
  port: ctx.config.HEALTH_PORT,  // hostname ausente → por defecto 0.0.0.0
  ...
});

En el VPS, ss -tlnp muestra:

LISTEN *:19210  users:(("bun",pid=578729,fd=15))

Bun escucha en todas las interfaces. Actualmente UFW bloquea :19210 externamente (HTTP 000 timeout desde el portátil), pero:

  1. curl http://62.171.128.248:19210/api/internal/bridges desde el propio VPS → HTTP 200 (loopback en IP pública)
  2. Cualquiera desde un contenedor en el VPS puede acceder a /api/internal/* SIN auth
  3. Un comando erróneo ufw allow 19210 — filtración global instantánea
  4. Migración a otro VPS sin UFW — filtración instantánea

/api/internal/bridges, /api/internal/chat/save, /api/internal/relay/:project/tool TODOS sin auth (comentario: "solo localhost, no expuesto via nginx").

Parche recomendado:

const server = Bun.serve({
  hostname: "127.0.0.1",  // ← añadir
  port: ctx.config.HEALTH_PORT,
  ...
});

⚠️ Nginx en el VPS hace proxy a 127.0.0.1:19210 — el parche no romperá el tráfico público.


CVE-S2 — /api/internal/chat/save acepta project_name arbitrario

Severidad: Media CWE: CWE-20 (Validación Incorrecta de Entradas) Archivo: master-bot/api-server.ts:376-398

Descripción: El endpoint inserta en chat_messages sin validación:

Parche recomendado:

if (!body.project_name || !isValidProjectName(body.project_name)) {
  return Response.json({ error: "Invalid project_name" }, { status: 400 });
}

CVE-S3 — Modo interactive en WebSocket via query-param

Severidad: Media Archivo: master-bot/api-server.ts:270

Descripción: ?mode=interactive abre un shell interactivo para cualquier token válido, sin ámbito de admin. El propio autor del código lo marcó como TODO: debt-7. Ver parche en CVE-C2.


🟢 HALLAZGOS MENORES

M1 — verifyToken: comparación de firma con string simple

Archivo: shared/auth.ts:270

if (signature !== expected) { return { valid: false, ...}; }

Mejor: crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)). HMAC-SHA256 256-bit — prácticamente seguro, pero es best practice.

M2 — Nginx: /config/ no bloqueado

Archivo: infra/nginx/citadel-crm.conf:179-180, 355-360

location ~ /\. { deny all; }
location ~ ^/(state|scripts)/ { deny all; }

CLAUDE.md dice: "blocked paths (/.*, /config/, /state/)" — la documentación no coincide con la realidad. Actualmente no es crítico (el tráfico / va a Docker, no al disco), pero:

M3 — safePath() TODO

Archivo: shared/crm-routes.ts:279

TODO: [debt-7] Apply to all endpoints, not just /files

Identificado por el propio proyecto. Revisión: verificar qué otros handlers aceptan rutas del usuario y no llaman a safePath.


✅ QUÉ FUNCIONA PERFECTAMENTE

Verificación Estado Evidencia
Vault no está en git git check-ignore config/vault.json → matched .gitignore:30
vault-key no está en git .gitignore:31 + chmod 600
data/citadel.db no está en git .gitignore:35
.env no está en git .gitignore:16
Bridge NotebookLM solo en localhost --host 127.0.0.1 en unit de systemd
NotebookLM no proxeado por Nginx Sin location /notebook* en el config
Regex isValidProjectName /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, máx. 64 caracteres
canAccessProject para /api/crm/projects/:name/* Gate en punto de entrada (:5689-5695)
Multi-tenancy en handleGetProjects Filtrado por owner_id (DB SSOT)
Vault AES-256-GCM createCipheriv("aes-256-gcm"...)
Escrituras atómicas tmp.${pid} + mv en writeVaultFile
JWT TTL 24h TOKEN_TTL_SEC = 24 * 60 * 60
Estado CSRF de OAuth TTL 10min, uso único (auth.ts:51-71)
Restablecimiento de contraseña TTL 30min RESET_TTL_MS
Verificación de email TTL 24h VERIFY_TTL_MS
Device code TTL 10min DEVICE_CODE_TTL_MS
Backup de owner_id en vps-sync.sh Backup antes de git pull, restaurar después
Smoke test de health en vps-sync.sh Esperar 401 en CRM sin auth
Test de path traversal en vps-sync.sh Esperar 401/403 en .hidden-traversal
UFW bloquea :19210/:19213/:19200 Test desde portátil: HTTP 000 timeout
Lista blanca CORS Env CRM_ALLOWED_ORIGINS
Headers CORS en respuestas de error Lección aprendida en CLAUDE.md
Modo WAL SQLite PRAGMA journal_mode = WAL
safePath() para endpoints /files resolve + startsWith + null en violación
Guard de path traversal en carga Path traversal blocked → 403

Plan de Parches (ordenado por prioridad)

Hoy (bloqueante para multi-usuario)

Esta semana

Backlog


Comandos Adicionales para Tests de Regresión

# Smoke test: SSE debe devolver 403 para proyecto ajeno
TOKEN_A=$(curl -s -X POST https://arc-os.co/api/auth/login -d '{"email":"a@test","password":"..."}' | jq -r .token)
curl -N "https://arc-os.co/api/sse/logs/b-project?token=$TOKEN_A"
# Esperado: 403 Forbidden (actualmente: 200 OK — VULN)

# Smoke test: terminal WebSocket del proyecto ajeno
websocat "wss://arc-os.co/ws/terminal/b-project?token=$TOKEN_A"
# Esperado: 403 (actualmente: upgrade OK — VULN)

# Smoke test: actualización de wiki MCP del proyecto ajeno
curl -X PUT "https://arc-os.co/api/mcp/wiki/b-project" \
  -H "Authorization: Bearer $TOKEN_A" \
  -d '{"file":"README","content":"pwned"}'
# Esperado: 403 (actualmente: 200 OK — VULN)

# Verificar bind de puerto tras parche CVE-S1:
ssh VPS "ss -tlnp | grep 19210"
# Esperado: 127.0.0.1:19210 (actualmente: *:19210)

Re-Auditoría (2026-04-23, post-Fase 42)

Cerrado: 11/11 hallazgos + 1 bonus (V0: path-traversal en handleSaveSkill — encontrado por el desarrollador).

Verificación de Parches

CVE Archivo:Línea Estado
C1 crm-routes.ts:2617, 2627, 2641
C2 api-server.ts:274-288 (guard + CEO/admin interactivo)
C3 api-server.ts:950-968 (regex entry-gate + skipGuard)
S1 api-server.ts:161 + prod ss -tlnp: 127.0.0.1:19210
S2 api-server.ts:404
M1 auth.ts:272 (timingSafeEqual + verificación de longitud antes del compare)
M2 citadel-crm.conf:180, 360 (deny-list ampliada)
M3 crm-routes.ts:4097, 4107, 4115 (regex + safePath doble seguridad)
V0 (bonus) crm-routes.ts:4097

Smoke Tests en Producción

ss -tlnp | grep 19210      # → 127.0.0.1:19210 (antes *:19210) ✅
curl 62.171.128.248:19210/api/internal/bridges --max-time 3  # → 000 timeout ✅
curl localhost:18888/api/crm/projects             # → 401 ✅

Nuevos Hallazgos de la Re-Auditoría

ID Severidad Archivo:Línea Descripción
FN-1 Media api-server.ts:956-967 El entry-gate C3 tiene patrón fail-open — si isValidProjectName === false, el guard se omite. Actualmente seguro (los handlers verifican por sí mismos), pero fail-closed es arquitectónicamente más sólido.
FN-2 Baja api-server.ts:955 Referencia muerta a /api/cli/chat-save en skipGuard — el endpoint no existe. Higiene del código.
FN-3 Baja api-server.ts:376-391 /api/internal/bridge-event/:project sin isValidProjectName. Protegido por S1 + UFW, pero es conveniente añadir doble seguridad.

Evaluación de Calidad

Cumplimiento Karpathy: 8.5/10

Tareas de Seguimiento

ID Prioridad
SEC-FN1 P2 — fail-closed en entry-gate C3
SEC-FN2 P3 — eliminar referencia muerta /api/cli/chat-save
SEC-V1 P2 — isValidProjectName + guard assertLocalhost en /api/internal/bridge-event/:project
SEC-V2 P2 — revisión de handleCliInit por filtración de env vía sustitución de plantilla
SEC-V3 P3 — test de regresión de path-traversal en vps-sync.sh

Firmado

Estándar Sentinel: Karpathy — precisión quirúrgica, minimalismo, crítica despiadada.

Estado: CERRADO — auditoría inicial + re-auditoría completadas. Veredicto de re-auditoría: 🟢 VERDE — trabajo aceptado.