Sicherheits-Audit-Bericht — Arc OS

Datum: 2026-04-23 Auditor: Sentinel (Super DevOps / Security Auditor) Projektphase: 40.18 Bereich: Backend (Bun :19210), Nginx (:18888/:443), NotebookLM (:19213), Vault, Multi-Tenancy


Executive Summary

Es wurde ein Sicherheitsaudit des Arc OS-Systems durchgeführt. Es wurden 3 kritische Multi-Tenancy-Schwachstellen gefunden, die einem authentifizierten Benutzer ermöglichen, auf Ressourcen anderer Projekte zuzugreifen (Logs, Terminal, Wiki, Issues). Derzeit speichert das Projekt keine vertraulichen Daten mehrerer Kunden, daher ist das Exploitations-Risiko gering, aber architektonisch ist das System nicht bereit für Multi-User-Produktion.

Urteil: YELLOW — C1–C3 sofort patchen, bevor echte Benutzer außerhalb des CEOs eingeladen werden.


🔴 KRITISCHE SCHWACHSTELLEN (3)

CVE-C1 — SSE-Streams ohne owner_id-Prüfung

Schweregrad: High CWE: CWE-285 (Improper Authorization) Dateien:

Beschreibung: Die Funktion routeSseRequest(pathname, query, registry) empfängt keine chatId und ruft canAccessProject nicht auf. Geprüft wird nur:

  1. JWT über crmAuthMiddleware
  2. isValidProjectName — nur um Path-Traversal zu verhindern

Exploitation:

# Benutzer A hat seinen Token über /api/auth/login erhalten
# Er weiß, dass das Projekt "victim-project" existiert (aus DB oder Raten)
curl -N "https://arc-os.co/api/sse/logs/victim-project?token=$MY_TOKEN"
# → empfängt JSONL-Log-Stream eines fremden Projekts in Echtzeit
# Dasselbe gilt für /api/sse/consultant/:name

Empfohlener Patch:

// shared/crm-routes.ts:2613
export function routeSseRequest(
  pathname: string,
  query: URLSearchParams,
  registry: Registry,
  chatId: string | null,  // ← hinzufügen
): 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);
  }
  // dasselbe für /api/sse/consultant/:name
}

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

CVE-C2 — WebSocket-Terminal ohne owner_id

Schweregrad: Critical CWE: CWE-285 Datei: master-bot/api-server.ts:250-283

Beschreibung: /ws/terminal/:name prüft JWT + isValidProjectName, aber nicht die Eigentümerschaft. Jeder authentifizierte Benutzer erhält Zugriff auf die tmux-Sitzung eines fremden Projekts. Im Modus ?mode=interactive ist das eine interaktive Shell.

Kommentar in :270 gesteht bereits:

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

Exploitation:

ws://arc-os.co/ws/terminal/victim-project?token=MY_TOKEN&mode=interactive
→ interaktive Shell-Sitzung in tmux eines fremden Projekts
→ vollständiger Lese-/Schreibzugriff auf das VPS-Dateisystem als Root

Empfohlener Patch:

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

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

// Interactive mode — nur 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/* ohne owner_id (12+ Endpunkte)

Schweregrad: High CWE: CWE-285 Datei: master-bot/api-server.ts:913-1084

Beschreibung: Der gesamte /api/cli/*- und /api/mcp/*-Block prüft nur JWT (crmAuthMiddleware) + isValidProjectName. Kein canAccessProject. Betroffene Endpunkte:

Endpunkt Methode Konsequenz
/api/cli/init/:project/:mode GET CLAUDE.md eines fremden Projekts erhalten
/api/cli/chat-log/:project POST Nachrichten in fremden Chat einschleusen
/api/mcp/skills/:project POST/GET Skills eines fremden Projekts ändern
/api/mcp/report/:project POST Bericht im Namen eines fremden Projekts senden
/api/mcp/learnings/:project GET learnings.md eines fremden Projekts lesen
/api/mcp/issues/:project POST/GET CRUD für Issues eines fremden Projekts
/api/mcp/issues/:project/:id PUT Fremde Issues ändern
/api/mcp/issues/:project/:id/log POST In fremden Activity-Trail schreiben
/api/mcp/wiki/:project PUT Wiki eines fremden Projekts überschreiben
/api/mcp/roadmap/:project GET/PUT Roadmap ändern

Empfohlener Patch:

// master-bot/api-server.ts:914 (direkt nach dem if-Block)
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) { ... }

  // DIESEN BLOCK HINZUFÜGEN:
  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 });
      }
    }
  }

  // ... bestehender Router ...
}

⚠️ Achtung: Download-Routen (/api/cli/download/...) und Device-Code (/api/cli/device/*) haben KEIN Projekt in der URL — sie sollen nicht blockiert werden. Reihenfolge der Bedingungen prüfen.


🟡 ERNSTHAFTE SCHWACHSTELLEN (3)

CVE-S1 — Bun.serve hört auf 0.0.0.0:19210

Schweregrad: Medium (Defense-in-Depth) CWE: CWE-668 (Exposure of Resource to Wrong Sphere) Datei: master-bot/api-server.ts:159-161

Beschreibung:

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

Auf dem VPS zeigt ss -tlnp:

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

Bun hört auf allen Interfaces. UFW blockiert :19210 von außen (HTTP 000 Timeout vom Laptop), aber:

  1. curl http://62.171.128.248:19210/api/internal/bridges vom VPS selbst → HTTP 200 (Loopback über öffentliche IP)
  2. Jeder Container auf dem VPS kann /api/internal/* OHNE Auth erreichen
  3. Ein falscher ufw allow 19210-Befehl — sofortiges globales Leck
  4. Migration auf einen anderen VPS ohne UFW — sofortiges Leck

/api/internal/bridges, /api/internal/chat/save, /api/internal/relay/:project/tool sind alle no-auth (Kommentar: "localhost-only, not exposed via nginx").

Empfohlener Patch:

const server = Bun.serve({
  hostname: "127.0.0.1",  // ← hinzufügen
  port: ctx.config.HEALTH_PORT,
  ...
});

⚠️ Nginx auf dem VPS proxiert 127.0.0.1:19210 — Patch bricht öffentlichen Traffic nicht.


CVE-S2 — /api/internal/chat/save akzeptiert beliebigen project_name

Schweregrad: Medium CWE: CWE-20 (Improper Input Validation) Datei: master-bot/api-server.ts:376-398

Beschreibung: Endpunkt fügt in chat_messages ohne Validierung ein:

Empfohlener Patch:

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

CVE-S3 — interactive-Modus WebSocket über Query-Parameter

Schweregrad: Medium Datei: master-bot/api-server.ts:270

Beschreibung: ?mode=interactive öffnet eine interaktive Shell für jeden gültigen Token, nicht admin-gebunden. Der Code-Autor hat selbst TODO: debt-7 markiert. Patch siehe CVE-C2.


🟢 GERINGFÜGIGE BEFUNDE

M1 — verifyToken: einfacher String-Vergleich für Signatur

Datei: shared/auth.ts:270

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

Besser: crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)). HMAC-SHA256 256-Bit — praktisch sicher, aber Best Practice.

M2 — Nginx: /config/ nicht blockiert

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

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

CLAUDE.md sagt: "blockierte Pfade (/.*, /config/, /state/)" — Dokumentation weicht von der Realität ab. Aktuell nicht kritisch (Traffic / geht in Docker, nicht auf Disk), aber:

M3 — safePath() TODO

Datei: shared/crm-routes.ts:279

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

Vom Projekt selbst gefunden. Überprüfung: prüfen, welche weiteren Handler Benutzerpfade akzeptieren und safePath nicht aufrufen.


✅ WAS AUSGEZEICHNET FUNKTIONIERT

Prüfung Status Nachweis
Vault nicht im Git git check-ignore config/vault.json → matched .gitignore:30
vault-key nicht im Git .gitignore:31 + chmod 600
data/citadel.db nicht im Git .gitignore:35
.env nicht im Git .gitignore:16
NotebookLM Bridge nur localhost --host 127.0.0.1 in systemd unit
NotebookLM nicht via Nginx proxiert Kein location /notebook* in Konfig
isValidProjectName Regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, max 64 Zeichen
canAccessProject für /api/crm/projects/:name/* Gate am Einstiegspunkt (:5689-5695)
handleGetProjects Multi-Tenancy Filtern nach owner_id (DB SSOT)
AES-256-GCM Vault createCipheriv("aes-256-gcm"...)
Atomare Schreibvorgänge tmp.${pid} + mv in writeVaultFile
JWT 24h TTL TOKEN_TTL_SEC = 24 * 60 * 60
OAuth CSRF-State 10 min TTL, Einmalnutzung (auth.ts:51-71)
Passwort-Reset 30 min TTL RESET_TTL_MS
E-Mail-Verifizierung 24h TTL VERIFY_TTL_MS
Device Code 10 min TTL DEVICE_CODE_TTL_MS
vps-sync.sh owner_id-Backup Backup vor git pull, Wiederherstellung danach
vps-sync.sh Health-Smoke-Test Erwartet 401 bei no-auth CRM
vps-sync.sh Path-Traversal-Test Erwartet 401/403 bei .hidden-traversal
UFW blockiert :19210/:19213/:19200 Test vom Laptop: HTTP 000 Timeout
CORS-Allowlist CRM_ALLOWED_ORIGINS env
CORS-Header bei Fehlerantworten In CLAUDE.md dokumentierte Lektion
WAL-Modus SQLite PRAGMA journal_mode = WAL
safePath() für /files-Endpunkte resolve + startsWith + null bei Verletzung
Path-Traversal-Guard beim Laden Path traversal blocked → 403

📋 Patch-Plan (Prioritätssortiert)

Heute (Blocker für Multi-User)

Diese Woche

Backlog


📎 Zusätzliche Befehle für Regressionstests

# Smoke-Test: SSE sollte 403 für fremdes Projekt zurückgeben
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"
# Erwartet: 403 Forbidden (aktuell: 200 OK — VULN)

# Smoke-Test: WebSocket-Terminal für fremdes Projekt
websocat "wss://arc-os.co/ws/terminal/b-project?token=$TOKEN_A"
# Erwartet: 403 (aktuell: Upgrade OK — VULN)

# Smoke-Test: MCP-Wiki-Update für fremdes Projekt
curl -X PUT "https://arc-os.co/api/mcp/wiki/b-project" \
  -H "Authorization: Bearer $TOKEN_A" \
  -d '{"file":"README","content":"pwned"}'
# Erwartet: 403 (aktuell: 200 OK — VULN)

# Port-Bind nach CVE-S1-Patch verifizieren:
ssh VPS "ss -tlnp | grep 19210"
# Erwartet: 127.0.0.1:19210 (aktuell: *:19210)

🔄 Re-Audit (2026-04-23, nach Phase 42)

Geschlossen: 11/11 Befunde + 1 Bonus (V0: handleSaveSkill Path-Traversal — vom Entwickler gefunden).

Patch-Verifizierung

CVE Datei:Zeile Status
C1 crm-routes.ts:2617, 2627, 2641
C2 api-server.ts:274-288 (Guard + CEO/Admin interactive)
C3 api-server.ts:950-968 (Entry-Gate Regex + skipGuard)
S1 api-server.ts:161 + Produktion ss -tlnp: 127.0.0.1:19210
S2 api-server.ts:404
M1 auth.ts:272 (timingSafeEqual + Length-Check vor Vergleich)
M2 citadel-crm.conf:180, 360 (Deny-List erweitert)
M3 crm-routes.ts:4097, 4107, 4115 (Regex + safePath belt+suspenders)
V0 (Bonus) crm-routes.ts:4097

Produktions-Smoke-Tests

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

Neue Befunde aus Re-Audit

ID Schweregrad Datei:Zeile Beschreibung
FN-1 Medium api-server.ts:956-967 C3 Entry-Gate hat Fail-Open-Muster — wenn isValidProjectName === false, wird Gate übersprungen. Aktuell sicher (Handler prüfen selbst), aber Fail-Closed ist architektonisch robuster.
FN-2 Low api-server.ts:955 Toter Verweis auf /api/cli/chat-save in skipGuard — Endpunkt existiert nicht. Hygiene.
FN-3 Low api-server.ts:376-391 /api/internal/bridge-event/:project ohne isValidProjectName. Geschützt durch S1 + UFW, aber Belt-and-Suspenders.

Qualitätsbewertung

Karpathy-Compliance: 8,5/10

Follow-up-Aufgaben

ID Priorität
SEC-FN1 P2 — Fail-Closed in C3 Entry-Gate
SEC-FN2 P3 — Toten /api/cli/chat-save-Verweis entfernen
SEC-V1 P2 — isValidProjectName + assertLocalhost-Guard in /api/internal/bridge-event/:project
SEC-V2 P2 — handleCliInit auf Env-Leck durch Template-Substitution überprüfen
SEC-V3 P3 — Path-Traversal-Regressionstest in vps-sync.sh

🔐 Abgezeichnet

Sentinel-Standard: Karpathy — chirurgische Präzision, Minimalismus, schonungslose Kritik.

Status: GESCHLOSSEN — Erstaudit + Re-Audit abgeschlossen. Re-Audit-Urteil: 🟢 GREEN — Arbeit abgenommen.