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:
shared/crm-routes.ts:2613-2639(routeSseRequest)master-bot/api-server.ts:402-410(Aufruf)
Beschreibung:
Die Funktion routeSseRequest(pathname, query, registry) empfängt keine chatId und ruft canAccessProject nicht auf. Geprüft wird nur:
- JWT über
crmAuthMiddleware 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:
curl http://62.171.128.248:19210/api/internal/bridgesvom VPS selbst → HTTP 200 (Loopback über öffentliche IP)- Jeder Container auf dem VPS kann
/api/internal/*OHNE Auth erreichen - Ein falscher
ufw allow 19210-Befehl — sofortiges globales Leck - 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:
body.project_name— keinisValidProjectName(kann../eviloder leer übergeben werden)- Keine Prüfung, ob Projekt existiert
- Kein owner_id (aber konzeptionell intern — hängt von S1 ab)
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:
location ~ ^/(state|scripts|config|data)/ { deny all; }für Defense-in-Depth hinzufügen.
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)
- C1:
routeSseRequest—canAccessProjecthinzufügen - C2:
/ws/terminal/:name—canAccessProject+ nur Admin fürinteractivehinzufügen - C3:
/api/cli/*+/api/mcp/*—canAccessProject-Gate auf Block-Ebene hinzufügen
Diese Woche
- S1:
Bun.serve({ hostname: "127.0.0.1" }) - S2:
isValidProjectNamein/api/internal/chat/save
Backlog
- M1:
timingSafeEqualinverifyToken - M2: Nginx:
/config/,/data/zur Deny-List hinzufügen - M3: Alle Endpunkte auf
safePathüberprüfen
📎 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
- 8/8 Patches korrekt, keine Regressionen
- Defense-in-Depth in M3 (Regex + safePath)
- Korrekter Length-Check VOR
timingSafeEqualin M1 - C2 interactive: CEO ODER Admin-Rolle (binäre Rolle) — korrekt
- Export-Helfer (
extractChatId,canAccessProject) — keine Logik-Duplizierung - Entwickler fand V0, das Sentinel übersehen hatte
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.