Files
PiCloud/SECURITY_AUDIT.md
MechaCat02 ec4a2aa24a style: cargo fmt across audit-2026-06-11 tier-3 changes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:38:28 +02:00

25 KiB

PiCloud Security Audit — 2026-06-11

10 parallel focused subagents reviewed the codebase at main (post-v1.1.9, pre-v1.2) for vulnerabilities in their assigned category. Each agent wrote a detailed findings file under security_audit/; this document is the rollup.

Scope and method

Each agent traced specific code paths end-to-end, cited file + line, classified by severity, and recommended a concrete fix. The audit covers the platform as deployed in single-node MVP mode (picloud binary + Caddy + Postgres + dashboard SPA). Cluster mode (picloud-manager / -orchestrator / -executor skeleton crates) is explicitly out of scope and surfaced only where it would silently degrade a current defense.

Severity rubric:

  • Critical — exploitable now by a low-privilege or anonymous attacker, with high impact (RCE, full takeover, cross-tenant data breach).
  • High — realistic exploit under normal deployment; privilege escalation, session hijack, easy DoS on the host.
  • Medium — defense-in-depth gap that becomes important under a second flaw; missing cap/quota that matters at scale.
  • Low — hardening; small impact even if exploited.
  • Info — observation worth recording, not a vulnerability.

Totals

# Agent C H M L I Findings file
01 AuthN, session & token lifecycle 0 2 6 4 5 01_authn_session.md
02 Authorization & cross-app isolation 0 1 4 4 4 02_authz_isolation.md
03 Cryptography & secret handling 0 2 5 3 6 03_crypto_secrets.md
04 Injection & query safety 0 0 5 4 9 04_injection.md
05 Rhai sandbox & script execution 0 4 6 5 4 05_sandbox_exec.md
06 File storage & path traversal 0 2 4 4 2 06_files_pathtraversal.md
07 HTTP, CORS, CSRF & frontend XSS 2 3 4 3 2 07_http_cors_csrf_xss.md
08 DoS, rate limiting & resource exhaustion 0 5 7 6 3 08_dos_resource.md
09 External integrations (SSRF, webhooks) 0 1 3 3 4 09_external_integrations.md
10 Information disclosure, logging, CLI 0 2 4 4 5 10_info_disclosure_cli.md
Total (raw) 2 22 48 40 44

A few findings show up in multiple agents (stored-XSS-via-uploads, dashboard-token-in-localStorage). De-duplicated below.

Headline

  • The boundaries that matter most are intact. SDK SdkCallCx.app_id discipline is held end-to-end (agent 2 traced two SDK paths Rhai→SQL and confirmed no script-controlled app_id enters the chain). SQL is parameterized cleanly across 75 dynamic call-sites (agent 4). Argon2id parameters, RNG selection, and constant-time HMAC verification are correct everywhere it matters (agents 1 and 3). SSRF defenses (link-local block + DNS-rebinding + redirect re-resolve + cross-origin Authorization scrub) are unusually thorough and verified end-to-end (agent 9). No unsafe blocks exist in executor-core or orchestrator-core; no sandbox-escape was found (agent 5).
  • The gaps cluster in HTTP-layer hardening and resource limits. There is effectively no defense-in-depth at the HTTP boundary (no CSP, no nosniff, no HSTS, no X-Frame-Options) and effectively no rate limiting on anonymous-reachable endpoints (login, email-inbound, fan-out triggers). The combination of cookie-auth + co-hosted user-route HTML + bare admin headers is what creates the two Critical findings.

Critical findings

Agent 7 → C07-01.

POST /auth/login (crates/manager-core/src/auth_api.rs:229) sets picloud_session=...; HttpOnly; Secure; SameSite=Lax. The auth middleware (auth_middleware.rs:91, 359) accepts the cookie as a fallback to Authorization: Bearer. User scripts serve arbitrary HTML on the same origin as the admin API (Caddyfile catch-all). SameSite=Lax does not block same-origin requests, so any user with AppScriptsWrite who publishes a script at e.g. /evil can host a page that POSTs to /api/v1/admin/... and the admin's cookie rides along.

Fix: drop the cookie auth path on /api/v1/admin/* (the dashboard already uses bearer tokens), or require both the cookie and a synchronizer-token header on state-changing methods.

C-2. Stored XSS via uploaded files served inline with no nosniff/CSP under the admin origin

Agent 7 → C07-02, agent 6 → F-FS-001 (joint).

GET /apps/{id}/files/{collection}/{file_id} (files_api.rs:130-164) streams blob bytes with Content-Disposition: inline; filename="...", the user-supplied Content-Type, no nosniff, no CSP. NewFile::validate only caps content_type length, no allowlist. A Rhai script (anonymous-callable HTTP route is enough) can stash an image/svg+xml or text/html blob; when an operator clicks Download in the dashboard, the file renders same-origin with the admin session cookie attached → session hijack.

Fix (response side, owned by agent 7's recommendation): serve all download responses with Content-Disposition: attachment, X-Content-Type-Options: nosniff, and Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors 'none'. Ideally serve file blobs from a distinct origin (files.<host>). Fix (storage side, agent 6): maintain a small MIME allowlist at NewFile::validate / FileUpdate::validate; force application/octet-stream on unknown types.

High-severity findings (22, grouped by theme)

HTTP hardening (4)

Both Criticals would be partially mitigated by these alone:

  • H-A1. No CSP anywhere — agent 7 H07-03 (caddy/Caddyfile{,.prod}, docker/dashboard.Dockerfile:28).
  • H-A2. No X-Frame-Options / frame-ancestors → dashboard is clickjackable — agent 7 H07-04.
  • H-A3. No HSTS in production Caddyfile — agent 7 H07-05.
  • H-A4. Dashboard stores bearer token in localStorage (XSS-readable) — agent 10 H1 / agent 1 Medium / agent 7 L07-12. Listed here because the CSP gap (H-A1) is what blocks the cheap fix. Agent 7 calls it Low because CSP is the upstream remediation.

Agent 7 ships a ready-to-paste Caddyfile snippet covering all four.

DoS / resource exhaustion on anonymous-reachable surfaces (5)

  • H-B1. Login endpoint has no rate limit; Argon2id per attempt → an anonymous attacker can pin every spawn_blocking worker on Argon2 in seconds on Pi-class hardware. agent 8 H-2, confirmed by agent 1.
  • H-B2. Email-inbound webhook accepts unsigned POSTs when inbound_secret is absent; with a secret configured there's still no per-(app_id, trigger_id) bad-signature bucket. agent 8 H-3, agent 9 F-EXT-M-001.
  • H-B3. No per-app cap on trigger / route / cron registration; one tenant can register thousands of cron rows or fan-out triggers and stall the node. agent 8 H-4.
  • H-B4. cron_scheduler::tick does fetch_all with no LIMIT, serially fires every due row inside one transaction. agent 8 H-5.
  • H-B5. Axum's default 2 MB body limit applies to email-inbound + admin JSON handlers; only the user-route handler (orchestrator-core/src/api.rs:219) is explicit at 10 MiB. No Caddy request_body { max_size }. agent 8 H-1.

Rhai sandbox (4)

  • H-C1. tokio::time::timeout over JoinHandle does not cancel spawn_blocking — a runaway script holds its OS thread until the op-budget self-exhausts. The code's own comment admits this. One anonymous script call can permanently subtract a worker from the blocking-thread pool. agent 5 F-SE-H-01. Fix: install engine.on_progress with a deadline check.
  • H-C2. SDK service calls (kv/docs/files/secrets/pubsub/queue/users/email/http/invoke) have no per-execution count cap. One script can chain 1 M kv reads, N file writes, N emails. agent 5 F-SE-H-02.
  • H-C3. engine.disable_symbol("print") but not debug — the latter writes attacker-controlled bytes to stderr (operator logs). agent 5 F-SE-H-03.
  • H-C4. ExecError::Runtime propagates verbatim Rhai/SDK error strings to the public HTTP response body — internal paths, "blocked by SSRF policy: link-local" reconnaissance, Rust backtrace fragments. agent 5 F-SE-H-04.

Cryptography (2)

  • H-D1. secrets ciphertext is not bound to (app_id, name) as AAD. Anyone with Postgres write access can ciphertext-swap rows across apps or rename-via-row-edit; the decrypt succeeds under the wrong identity, returning attacker-chosen plaintext. Same gap on app_secrets.realtime_signing_key and the email-trigger inbound_secret. agent 3 first High + email-trigger Medium + app-secrets Medium. Fix: bind aad = "secret:{app_id}:{name}" and analogues; fold into v1.2's planned key-versioning + re-encryption pass.
  • H-D2. Dev-key fallback is SHA-256("picloud-dev-master-key-v1.1.7") — a leaked dev-mode DB dump is trivially decryptable. The PICLOUD_DEV_INSECURE_KEY ack helps but the warning is a single tracing::warn!. agent 3 second High.

Authentication & session lifecycle (2)

  • H-E1. Dashboard self-password-change does not invalidate other live sessions or API keys (admin_users_api.rs:232-241). CLI reset-password and account deactivation both do invalidate; only the in-product UI rotation is silent — the natural reaction to suspected compromise. agent 1.
  • H-E2. Bootstrap can be re-triggered by hard-deleting all admin rows (admin_users is delete_admin-hard-DELETE, no soft-delete) when PICLOUD_BOOTSTRAP_* env vars are left in compose. agent 1.

Authorization defense-in-depth (1)

  • H-F1. The queue-trigger dispatcher (dispatcher.rs:290-340) builds ExecRequest with app_id: claimed.app_id and script_id: consumer.script_id but does not cross-check script.app_id == consumer.app_id — unlike its sibling build_invoke_request (line 752), which does. Safe today by construction (validate_trigger_target enforces same-app at write time) but no runtime defense; a hand-edited trigger row or partial backup restore would silently execute one app's script under another app's SdkCallCx. Same gap on build_http_request (Low). agent 2.

File storage defense-in-depth (1)

  • H-G1. Admin file endpoints (list_files, get_file, delete_file) skip validate_files_collection; repo head/get/list/delete skip guard_collection. Only create/update validate. Safe today but one bad migration / restore tool can insert a row with collection = "../../etc" and the next get_file reads arbitrary host files. agent 6 F-FS-002.

External integrations (1)

  • H-H1. email::send from scripts has no rate limit. The EmailRateLimiter token bucket exists but is wired only into users_service (verification / password-reset flows). A public-HTTP-callable script can burn the operator's SMTP quota or BCC-bomb thousands of recipients per request. agent 9 F-EXT-H-001.

CLI / operator tooling (1)

  • H-J1. pic admins create/set --password VALUE and pic login --token VALUE accept secrets on argv — they land in shell history, ps aux, and /proc/<pid>/cmdline. Help text warns; the flag still works. Also, both pic admins' read_password_from_stdin and picloud admin reset-password's prompt advertise "no echo" but use read_line, which echoes. agent 10 H2 + M2 + L1.

Mediums and Lows worth surfacing

Full lists live in each agent's file. Highlights:

  • Admin sessions have no absolute (hard-cap) expiry — only sliding TTL — agent 1.
  • Password change does not require current-password re-verification — a hijacked session escalates to permanent ownership — agent 1.
  • PICLOUD_COOKIE_SECURE=off accepted silently even when PUBLIC_BASE_URL is HTTPS — agent 1.
  • get_script / list_routes_for_script return 404 before authz → cross-app id enumeration — agent 2.
  • routes:check / routes:match trust a body-supplied app_id — agent 2.
  • Inactive-principal cron jobs never get the trigger disabled → re-fires on reactivation under reduced privileges — agent 2.
  • MasterKey + decrypted plaintexts never zeroize on drop — agent 3.
  • auth_middleware::resolve_principal principal-cache is keyed by token-hash with no eviction on logout/password-change/deactivation — revocation lag bounded only by TTL — agent 3.
  • content_type accepts CRLF → response-header injection / handler panic on download — agent 4.
  • tracing text-formatter is vulnerable to log forging via script-controlled queue/topic/collection names — agent 4.
  • serde_json::from_str in json::parse SDK and email-inbound has no recursion / size limits → stack overflow → process kill — agents 4 and 5.
  • No max_modules_per_execution on Rhai imports; invoke() re-entry inherits caller's permits — agent 5.
  • No per-app quota on files / KV / docs / queue depth / queue count — agent 6, agent 8.
  • No X-Content-Type-Options: nosniff / Referrer-Policy / Permissions-Policy / Cache-Control: no-store anywhere — agent 7.
  • Outbox has no time-based retention sweep; stuck-claimed rows accumulate — agent 8.
  • No SSE subscriber cap per IP / app / process — agent 8.
  • No global cap on in-flight HTTP connections / slowloris exposure — agent 8.
  • HTTP-async retries have no per-target circuit breaker — agent 8.
  • http_service::validate_url blocks ports 22/25/465/587 but not 23/110/143/993/995/3306/6379 — narrow exploit window but allow-list would be safer — agent 9.
  • Distinguishable 404s ("no app claims host" vs "no route matches") enable app enumeration via Host header — agent 10.

Cross-cutting recommendations (priority order)

Closing these in roughly this order would land the most defensive value per unit work.

Tier 1 — close the Criticals (no schema changes; one PR each)

  1. Drop cookie auth on /api/v1/admin/* (or require synchronizer token). Closes C-1.
  2. Force download responses to Content-Disposition: attachment + nosniff + restrictive CSP on file-serving routes; add a MIME allowlist at upload validation. Closes C-2. Joint owner: agents 6+7.
  3. Add the security-header layer to Caddy (CSP for /admin/* and /api/v1/admin/*, nosniff everywhere, HSTS in prod, frame-ancestors 'none'). Closes H-A1, H-A2, H-A3 and turns H-A4 (localStorage token) from acute to defense-in-depth. Agent 7 ships a ready-to-paste snippet.

Tier 2 — close the highest-leverage Highs

  1. Login rate limit + global Argon2 semaphore (H-B1). Cheapest CPU-DoS in the system; pattern already exists in EmailRateLimiter.
  2. Install engine.on_progress deadline check so wall-clock budget actually cancels a runaway Rhai loop (H-C1).
  3. Bind aad = "secret:{app_id}:{name}" in the AES-GCM seal/open paths for secrets, app_secrets, and email-trigger inbound secrets (H-D1). Fold into the v1.2 key-versioning pass already on the roadmap.
  4. Wipe other sessions + revoke all API keys on self-password-change (H-E1). Mirror cmd_reset_password's behavior.
  5. Make inbound_secret mandatory on email triggers; add per-(app_id, trigger_id) bad-signature token bucket (H-B2).
  6. Mirror the script.app_id == row.app_id cross-check from build_invoke_request into queue + HTTP dispatcher arms (H-F1, plus the Low sibling in build_http_request).
  7. Rate-limit email::send from scripts by moving EmailRateLimiter into EmailServiceImpl and adding a per-message recipient cap (H-H1).

Tier 3 — Hardening sweep

  1. Per-app caps: trigger/route/cron count (H-B3), cron-scheduler LIMIT 1000 per tick (H-B4), explicit DefaultBodyLimit at router root + Caddy request_body { max_size } (H-B5).
  2. Add per-execution SDK counters to SdkCallCx (kv-ops, files, emails, http, pubsub publishes, invoke depth) — closes H-C2 and bounds invoke amplification.
  3. engine.disable_symbol("debug") — one-line, closes H-C3.
  4. Scrub ExecError::Runtime for unauthenticated callers — closes H-C4 plus the principal-leak from join-panic strings.
  5. Belt-and-suspenders collection validation in repo head/get/list/delete and validate_files_collection at admin endpoints — closes H-G1.
  6. Bootstrap sentinel: replace count_active() > 0 gate with a persistent bootstrap_done marker — closes H-E2.
  7. Reject --password VALUE / --token VALUE argv flags; require --password - (stdin). Fix read_linerpassword::prompt_password on every echo-claiming prompt. Closes H-J1.

Tier 4 — Quotas and observability (paves the way for v1.2)

  1. Per-app aggregate quotas: KV row count, files bytes, queue depth, queue count, docs collection size — agents 6 and 8.
  2. Absolute (hard-cap) session expiry on admin_sessions mirroring app_user_sessions — agent 1.
  3. Audit-log table for admin actions (api_key_minted, user_promoted, app_deleted, etc.) — agent 10 I4.
  4. Outbox GC sweep + circuit-breaker for HTTP-async retries — agent 8 M-1 / M-5.
  5. AAD migration sweep + Zeroize on MasterKey and intermediate decrypted plaintexts — agent 3.

Verified-clean / no-finding observations

These were investigated and judged adequate for the threat model. Recording so future audits don't re-trace:

  • SDK app_id discipline. No Rhai-callable service-trait method accepts app_id as a side parameter. Two paths (kv::set, dead_letters::replay) traced end-to-end Rhai→SQL — agent 2.
  • SQL injection. 75 dynamic sqlx::query(...) callsites; all use positional $N binds. Two format!-into-SQL patterns interpolate hardcoded const &str column lists only. The DSL builder (docs_repo::build_find_query) binds every user-supplied path segment and value via QueryBuilder::push_bind — agent 4.
  • Argon2id parameters, RNG selection, constant-time HMAC verification, session-token entropy (256 bits via OsRng), timing-flat username enumeration (dummy-hash path) — agents 1, 3.
  • SSRF. Link-local + private-IP block at parse time; DNS-rebinding defense; redirect re-resolution; cross-origin Authorization scrub. End-to-end trace from Rhai http::getvalidate_urlpolicy.check — agent 9.
  • CLI on-disk credentials are mode-0600 enforced with re-set on each write; unit test pins it — agent 10.
  • No {@html}, innerHTML, eval, or Function() in the dashboard; no external CDN <script> tags; CodeMirror does not evaluate Rhai as JS — agent 7.
  • process::Command confined to test code; no shell-out from production paths — agent 4.
  • Migrations are static SQL — no EXECUTE format(...) blocks; schema-snapshot test pins the final shape — agent 4.
  • No sandbox escape (no unsafe in executor-core / orchestrator-core; no eval/import reachable from scripts beyond the per-app module resolver; no file/process/network primitives registered) — agent 5.
  • Caddy admin is admin off in dev, localhost-default in prod; not network-reachable — agent 9.
  • /version returns only product/sdk/api/schema/wire versions + public_base_url; no build SHA, OS, or internal IP. /healthz is literally "ok" — agent 10.

Per-agent index

Each file has full findings, recommendations, and traces:

  1. 01_authn_session.md — login flow, password hashing, session tokens, bootstrap, token transport
  2. 02_authz_isolation.md — capability gates, SdkCallCx.app_id, resolve_app, dispatcher fan-out, slug-history
  3. 03_crypto_secrets.md — master key, AES-GCM envelope, secrets/app-secrets/email-trigger seal/open, API keys
  4. 04_injection.md — SQL, JSONB, log injection, response-header injection, SMTP injection, regex DoS, command injection
  5. 05_sandbox_exec.md — Rhai engine limits, operation budgets, SDK surface, error propagation
  6. 06_files_pathtraversal.md — files API, on-disk layout, traversal guards, atomic writes, MIME handling
  7. 07_http_cors_csrf_xss.md — security headers, CORS, CSRF, SPA XSS, cookie/session flags
  8. 08_dos_resource.md — concurrency caps, rate limits, body limits, fan-out budgets, quota gaps
  9. 09_external_integrations.md — SSRF, HTTP-async, email inbound/outbound, Postgres TLS, CLI TLS
  10. 10_info_disclosure_cli.md — error response bodies, log redaction, CLI token files, dashboard token storage

Notes on remediation methodology

  • Most fixes are mechanical and don't require schema changes — Tier 1 + Tier 2 (items 1-10) can each land as a single focused PR with tests.
  • AAD migration (item 6) needs a startup re-encryption sweep; pair it with the v1.2 key-versioning pass already on the roadmap (memory: Release state snapshot 2026-06-07).
  • Per-app quotas (item 18) need an app_quotas schema migration; defer to v1.2.
  • The audit deliberately did not exercise cluster mode (skeleton crates). The crypto AAD migration and inbound nonce dedup both regress under cluster mode if shipped as-is — call out in the v1.3 readiness checklist.
  • Severity calibration assumes solo-dev / Pi-class hardware. A single anonymous request fully saturating one execution worker for 5 minutes is treated as High, not Low.