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:
shared/crm-routes.ts:2613-2639(routeSseRequest)master-bot/api-server.ts:402-410(llamada)
Descripción:
La función routeSseRequest(pathname, query, registry) no recibe chatId y no llama a canAccessProject. Solo se verifica:
- JWT a través de
crmAuthMiddleware 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:
curl http://62.171.128.248:19210/api/internal/bridgesdesde el propio VPS → HTTP 200 (loopback en IP pública)- Cualquiera desde un contenedor en el VPS puede acceder a
/api/internal/*SIN auth - Un comando erróneo
ufw allow 19210— filtración global instantánea - 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:
body.project_name— sinisValidProjectName(se puede pasar../evilo vacío)- No se verifica que el proyecto exista
- Sin owner_id (pero esto está pensado como interno — depende de S1)
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:
- añadir
location ~ ^/(state|scripts|config|data)/ { deny all; }para defensa en profundidad.
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)
- C1:
routeSseRequest— añadircanAccessProject - C2:
/ws/terminal/:name— añadircanAccessProject+ solo admin parainteractive - C3:
/api/cli/*+/api/mcp/*— añadir gatecanAccessProjecta nivel del bloque
Esta semana
- S1:
Bun.serve({ hostname: "127.0.0.1" }) - S2:
isValidProjectNameen/api/internal/chat/save
Backlog
- M1:
timingSafeEqualenverifyToken - M2: Nginx: añadir
/config/,/data/a la lista de denegación - M3: Revisión de todos los endpoints para
safePath
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
- 8/8 parches correctos, sin regresiones
- Defensa en profundidad en M3 (regex + safePath)
- Correcta verificación de longitud ANTES de
timingSafeEqualen M1 - C2 interactivo: CEO O rol admin (rol binario) — correcto
- Exportación de helpers (
extractChatId,canAccessProject) — sin duplicación de lógica - El desarrollador encontró V0, que Sentinel pasó por alto
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.