Raport z audytu bezpieczeństwa — Arc OS
Data: 2026-04-23 Audytor: Sentinel (Super DevOps / Security Auditor) Faza projektu: 40.18 Zakres: Backend (Bun :19210), Nginx (:18888/:443), NotebookLM (:19213), Vault, Multi-tenancy
Podsumowanie wykonawcze
Przeprowadzono audyt bezpieczeństwa systemu Arc OS. Znaleziono 3 krytyczne podatności multi-tenancy, umożliwiające uwierzytelnionemu użytkownikowi dostęp do zasobów cudzych projektów (logi, terminal, wiki, zgłoszenia). Projekt nie przechowuje aktualnie poufnych danych wielu klientów, więc ryzyko eksploatacji jest niskie, ale architektonicznie system nie jest gotowy do multi-user production.
Werdykt: ŻÓŁTY — natychmiastowe łatanie C1–C3, zanim zaprosi się prawdziwych użytkowników spoza CEO.
🔴 KRYTYCZNE PODATNOŚCI (3)
CVE-C1 — Strumienie SSE bez weryfikacji owner_id
Severity: High CWE: CWE-285 (Improper Authorization) Pliki:
shared/crm-routes.ts:2613-2639(routeSseRequest)master-bot/api-server.ts:402-410(wywołanie)
Opis:
Funkcja routeSseRequest(pathname, query, registry) nie otrzymuje chatId i nie wywołuje canAccessProject. Weryfikowane jest wyłącznie:
- JWT przez
crmAuthMiddleware isValidProjectName— tylko żeby nie było path traversal
Eksploatacja:
# Użytkownik A uzyskał swój token przez /api/auth/login
# Wie, że istnieje projekt "victim-project" (z DB lub zgadywania)
curl -N "https://arc-os.co/api/sse/logs/victim-project?token=$MY_TOKEN"
# → otrzymuje strumień JSONL logów cudzego projektu w czasie rzeczywistym
# To samo dla /api/sse/consultant/:name
Zalecana łatka:
// shared/crm-routes.ts:2613
export function routeSseRequest(
pathname: string,
query: URLSearchParams,
registry: Registry,
chatId: string | null, // ← dodaj
): 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);
}
// to samo dla /api/sse/consultant/:name
}
// master-bot/api-server.ts:406
const chatId = extractChatId(req); // eksportuj z crm-routes
const sseResponse = routeSseRequest(url.pathname, url.searchParams, ctx.registry, chatId);
CVE-C2 — Terminal WebSocket bez owner_id
Severity: Critical
CWE: CWE-285
Plik: master-bot/api-server.ts:250-283
Opis:
/ws/terminal/:name weryfikuje JWT + isValidProjectName, ale nie sprawdza własności. Każdy uwierzytelniony użytkownik uzyskuje dostęp do sesji tmux cudzego projektu. W trybie ?mode=interactive to interaktywny shell.
Komentarz na :270 sam się przyznaje:
const interactive = url.searchParams.get("mode") === "interactive";
// TODO: [debt-7] Gate behind admin-scoped token, not just query param
Eksploatacja:
ws://arc-os.co/ws/terminal/victim-project?token=MY_TOKEN&mode=interactive
→ interaktywna sesja shell w tmux cudzego projektu
→ pełny odczyt/zapis w systemie plików VPS jako root
Zalecana łatka:
// master-bot/api-server.ts:258
const projectName = decodeURIComponent(wsMatch[1]);
if (!isValidProjectName(projectName)) { ... }
// DODAJ:
const chatId = verifyToken(token).chatId;
if (!canAccessProject(ctx.registry, chatId, projectName)) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
// Tryb interactive — tylko 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/* bez owner_id (12+ endpointów)
Severity: High
CWE: CWE-285
Plik: master-bot/api-server.ts:913-1084
Opis:
Cały blok /api/cli/* i /api/mcp/* weryfikuje tylko JWT (crmAuthMiddleware) + isValidProjectName. Żadnego canAccessProject. Dotknięte endpointy:
| Endpoint | Metoda | Konsekwencja |
|---|---|---|
/api/cli/init/:project/:mode |
GET | Pobranie CLAUDE.md cudzego projektu |
/api/cli/chat-log/:project |
POST | Wstawianie wiadomości do cudzego chatu |
/api/mcp/skills/:project |
POST/GET | Zmiana skilli cudzego projektu |
/api/mcp/report/:project |
POST | Wysłanie raportu w imieniu cudzego |
/api/mcp/learnings/:project |
GET | Odczyt learnings.md cudzego |
/api/mcp/issues/:project |
POST/GET | CRUD zgłoszeń cudzego projektu |
/api/mcp/issues/:project/:id |
PUT | Zmiana cudzych zgłoszeń |
/api/mcp/issues/:project/:id/log |
POST | Zapis w activity trail |
/api/mcp/wiki/:project |
PUT | Nadpisanie wiki cudzego projektu |
/api/mcp/roadmap/:project |
GET/PUT | Zmiana roadmapy |
Zalecana łatka:
// master-bot/api-server.ts:914 (zaraz po bloku 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) { ... }
// DODAJ TEN BLOK:
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 });
}
}
}
// ... istniejący router ...
}
⚠️ Uwaga: trasy download (/api/cli/download/...) i device-code (/api/cli/device/*) NIE MAJĄ projektu w URL — nie powinny być blokowane. Sprawdź kolejność warunków.
🟡 POWAŻNE PODATNOŚCI (3)
CVE-S1 — Bun.serve nasłuchuje na 0.0.0.0:19210
Severity: Medium (defense-in-depth)
CWE: CWE-668 (Exposure of Resource to Wrong Sphere)
Plik: master-bot/api-server.ts:159-161
Opis:
const server = Bun.serve({
port: ctx.config.HEALTH_PORT, // brak hostname → domyślnie 0.0.0.0
...
});
Na VPS ss -tlnp pokazuje:
LISTEN *:19210 users:(("bun",pid=578729,fd=15))
Bun nasłuchuje na wszystkich interfejsach. Aktualnie UFW blokuje :19210 zewnętrznie (timeout HTTP 000 z laptopa), ale:
curl http://62.171.128.248:19210/api/internal/bridgesz samego VPS → HTTP 200 (loopback na publicznym IP)- Każdy z kontenera na VPS może kontaktować się z
/api/internal/*BEZ auth - Jedna błędna komenda
ufw allow 19210— natychmiastowy globalny wyciek - Migracja na inny VPS bez UFW — natychmiastowy wyciek
/api/internal/bridges, /api/internal/chat/save, /api/internal/relay/:project/tool WSZYSTKIE bez auth (komentarz: „localhost-only, not exposed via nginx").
Zalecana łatka:
const server = Bun.serve({
hostname: "127.0.0.1", // ← dodaj
port: ctx.config.HEALTH_PORT,
...
});
⚠️ Nginx na VPS proksuje 127.0.0.1:19210 — łatka nie zepsuje ruchu publicznego.
CVE-S2 — /api/internal/chat/save przyjmuje dowolny project_name
Severity: Medium
CWE: CWE-20 (Improper Input Validation)
Plik: master-bot/api-server.ts:376-398
Opis:
Endpoint wstawia do chat_messages bez walidacji:
body.project_name— brakisValidProjectName(można przekazać../evillub pusty)- Brak sprawdzenia, że projekt istnieje
- Brak owner_id (ale to zamierzone jako internal — zależy od S1)
Zalecana łatka:
if (!body.project_name || !isValidProjectName(body.project_name)) {
return Response.json({ error: "Invalid project_name" }, { status: 400 });
}
CVE-S3 — Tryb interactive WebSocket przez query-param
Severity: Medium
Plik: master-bot/api-server.ts:270
Opis:
?mode=interactive otwiera interaktywny shell dla każdego ważnego tokenu, nie admin-scoped. Autor kodu sam oznaczył TODO: debt-7. Zobacz łatkę w CVE-C2.
🟢 DROBNE USTALENIA
M1 — verifyToken: proste porównanie string signature
Plik: shared/auth.ts:270
if (signature !== expected) { return { valid: false, ...}; }
Lepiej: crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)). HMAC-SHA256 256-bit — praktycznie bezpieczne, ale best practice.
M2 — Nginx: /config/ niezablokowany
Plik: infra/nginx/citadel-crm.conf:179-180, 355-360
location ~ /\. { deny all; }
location ~ ^/(state|scripts)/ { deny all; }
CLAUDE.md mówi: „blocked paths (/.*, /config/, /state/)" — dokumentacja rozchodzi się z rzeczywistością. Aktualnie niekrytyczne (ruch / idzie do Dockera, nie na dysk), ale:
- dodaj
location ~ ^/(state|scripts|config|data)/ { deny all; }dla defence-in-depth.
M3 — safePath() TODO
Plik: shared/crm-routes.ts:279
TODO: [debt-7] Apply to all endpoints, not just /files
Znalezione przez sam projekt. Rewizja: sprawdź, które handlery przyjmują ścieżki użytkownika i nie wywołują safePath.
✅ CO DZIAŁA DOSKONALE
| Sprawdzenie | Status | Dowód |
|---|---|---|
| Vault nie w git | ✅ | git check-ignore config/vault.json → matched .gitignore:30 |
| vault-key nie w git | ✅ | .gitignore:31 + chmod 600 |
data/citadel.db nie w git |
✅ | .gitignore:35 |
.env nie w git |
✅ | .gitignore:16 |
| NotebookLM bridge tylko localhost | ✅ | --host 127.0.0.1 w jednostce systemd |
| NotebookLM nieproksowany przez Nginx | ✅ | Brak location /notebook* w konfigu |
Regex isValidProjectName |
✅ | /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, maks. 64 znaki |
canAccessProject dla /api/crm/projects/:name/* |
✅ | Bramka w punkcie wejścia (:5689-5695) |
Multi-tenancy handleGetProjects |
✅ | Filtrowanie przez owner_id (DB jako SSOT) |
| Vault AES-256-GCM | ✅ | createCipheriv("aes-256-gcm"...) |
| Atomiczne zapisy | ✅ | tmp.${pid} + mv w writeVaultFile |
| JWT TTL 24h | ✅ | TOKEN_TTL_SEC = 24 * 60 * 60 |
| Token CSRF OAuth | ✅ | TTL 10 min, jednorazowy (auth.ts:51-71) |
| TTL resetu hasła 30 min | ✅ | RESET_TTL_MS |
| TTL weryfikacji email 24h | ✅ | VERIFY_TTL_MS |
| TTL kodu urządzenia 10 min | ✅ | DEVICE_CODE_TTL_MS |
Backup owner_id w vps-sync.sh |
✅ | Backup przed git pull, przywracanie po |
Test dymny zdrowia w vps-sync.sh |
✅ | Oczekuje 401 przy CRM bez auth |
Test path traversal w vps-sync.sh |
✅ | Oczekuje 401/403 na .hidden-traversal |
UFW blokuje :19210/:19213/:19200 |
✅ | Test z laptopa: HTTP 000 timeout |
| Whitelist CORS | ✅ | env CRM_ALLOWED_ORIGINS |
| Nagłówki CORS na odpowiedziach błędów | ✅ | Lekcja w CLAUDE.md |
| Tryb WAL SQLite | ✅ | PRAGMA journal_mode = WAL |
safePath() dla endpointów /files |
✅ | resolve + startsWith + null przy naruszeniu |
| Ochrona przed path traversal przy pobieraniu | ✅ | Path traversal blocked → 403 |
📋 Plan łatek (priorytetowo)
Dzisiaj (blokada dla multi-user)
- C1:
routeSseRequest— dodajcanAccessProject - C2:
/ws/terminal/:name— dodajcanAccessProject+ tylko-admin dlainteractive - C3:
/api/cli/*+/api/mcp/*— dodaj bramkęcanAccessProjectna poziomie bloku
Ten tydzień
- S1:
Bun.serve({ hostname: "127.0.0.1" }) - S2:
isValidProjectNamew/api/internal/chat/save
Backlog
- M1:
timingSafeEqualwverifyToken - M2: Nginx: dodaj
/config/,/data/do listy deny - M3: Rewizja wszystkich endpointów pod kątem
safePath
📎 Dodatkowe komendy do testów regresji
# Test dymny: SSE powinno zwracać 403 dla cudzego projektu
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"
# Oczekiwane: 403 Forbidden (aktualnie: 200 OK — PODATNOŚĆ)
# Test dymny: terminal WebSocket cudzego projektu
websocat "wss://arc-os.co/ws/terminal/b-project?token=$TOKEN_A"
# Oczekiwane: 403 (aktualnie: upgrade OK — PODATNOŚĆ)
# Test dymny: aktualizacja wiki MCP cudzego projektu
curl -X PUT "https://arc-os.co/api/mcp/wiki/b-project" \
-H "Authorization: Bearer $TOKEN_A" \
-d '{"file":"README","content":"pwned"}'
# Oczekiwane: 403 (aktualnie: 200 OK — PODATNOŚĆ)
# Weryfikacja bindowania portu po łatce CVE-S1:
ssh VPS "ss -tlnp | grep 19210"
# Oczekiwane: 127.0.0.1:19210 (aktualnie: *:19210)
🔄 Ponowny audyt (2026-04-23, po Phase 42)
Zamknięte: 11/11 ustaleń + 1 bonus (V0: path-traversal handleSaveSkill — znalazł deweloper).
Weryfikacja łatek
| CVE | Plik:Linia | Status |
|---|---|---|
| C1 | crm-routes.ts:2617, 2627, 2641 |
✅ |
| C2 | api-server.ts:274-288 (guard + interactive CEO/admin) |
✅ |
| C3 | api-server.ts:950-968 (regex bramki wejścia + skipGuard) |
✅ |
| S1 | api-server.ts:161 + prod ss -tlnp: 127.0.0.1:19210 |
✅ |
| S2 | api-server.ts:404 |
✅ |
| M1 | auth.ts:272 (timingSafeEqual + sprawdzenie długości przed compare) |
✅ |
| M2 | citadel-crm.conf:180, 360 (lista deny rozszerzona) |
✅ |
| M3 | crm-routes.ts:4097, 4107, 4115 (regex + safePath belt+suspenders) |
✅ |
| V0 (bonus) | crm-routes.ts:4097 |
✅ |
Produkcyjne testy dymne
ss -tlnp | grep 19210 # → 127.0.0.1:19210 (było *:19210) ✅
curl 62.171.128.248:19210/api/internal/bridges --max-time 3 # → 000 timeout ✅
curl localhost:18888/api/crm/projects # → 401 ✅
Nowe ustalenia ponownego audytu
| ID | Severity | Plik:Linia | Opis |
|---|---|---|---|
| FN-1 | Medium | api-server.ts:956-967 |
Bramka wejścia C3 ma wzorzec fail-open — jeśli isValidProjectName === false, guard jest pomijany. Aktualnie bezpieczne (handlery sprawdzają samodzielnie), ale fail-closed jest architektonicznie silniejszy. |
| FN-2 | Low | api-server.ts:955 |
Martwa referencja do /api/cli/chat-save w skipGuard — endpoint nie istnieje. Higiena. |
| FN-3 | Low | api-server.ts:376-391 |
/api/internal/bridge-event/:project bez isValidProjectName. Chronione przez S1 + UFW, ale belt+suspenders. |
Ocena jakości
- 8/8 łatek poprawnych, bez regresji
- Defence-in-depth w M3 (regex + safePath)
- Prawidłowe sprawdzenie długości PRZED
timingSafeEqualw M1 - C2 interactive: CEO OR rola admin (binarna rola) — poprawnie
- Eksport helperów (
extractChatId,canAccessProject) — bez duplikowania logiki - Deweloper znalazł V0, który Sentinel przeoczył
Zgodność Karpathy: 8,5/10
Zadania do wykonania
| ID | Priorytet |
|---|---|
| SEC-FN1 | P2 — fail-closed w bramce wejścia C3 |
| SEC-FN2 | P3 — usuń martwą referencję /api/cli/chat-save |
| SEC-V1 | P2 — isValidProjectName + guard assertLocalhost w /api/internal/bridge-event/:project |
| SEC-V2 | P2 — rewizja handleCliInit pod kątem wycieku env przez podstawianie szablonów |
| SEC-V3 | P3 — test regresji path-traversal w vps-sync.sh |
🔐 Podpisano
Standard Sentinel: Karpathy — chirurgiczna precyzja, minimalizm, bezwzględna krytyczność.
Status: ZAMKNIĘTY — audyt pierwotny + audyt ponowny ukończone.
Werdykt ponownego audytu: 🟢 ZIELONY — praca przyjęta.