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_iddiscipline is held end-to-end (agent 2 traced two SDK paths Rhai→SQL and confirmed no script-controlledapp_identers 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-originAuthorizationscrub) are unusually thorough and verified end-to-end (agent 9). Nounsafeblocks exist inexecutor-coreororchestrator-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
C-1. Same-origin CSRF on /api/v1/admin/* via SameSite=Lax cookie + co-hosted user-route HTML
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_blockingworker 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_secretis 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::tickdoesfetch_allwith noLIMIT, 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::timeoutoverJoinHandledoes not cancelspawn_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: installengine.on_progresswith 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 notdebug— the latter writes attacker-controlled bytes to stderr (operator logs). agent 5 F-SE-H-03. - H-C4.
ExecError::Runtimepropagates 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.
secretsciphertext 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 onapp_secrets.realtime_signing_keyand the email-triggerinbound_secret. agent 3 first High + email-trigger Medium + app-secrets Medium. Fix: bindaad = "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. ThePICLOUD_DEV_INSECURE_KEYack helps but the warning is a singletracing::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-passwordand 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_usersisdelete_admin-hard-DELETE, no soft-delete) whenPICLOUD_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
ExecRequestwithapp_id: claimed.app_idandscript_id: consumer.script_idbut does not cross-checkscript.app_id == consumer.app_id— unlike its siblingbuild_invoke_request(line 752), which does. Safe today by construction (validate_trigger_targetenforces 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'sSdkCallCx. Same gap onbuild_http_request(Low). agent 2.
File storage defense-in-depth (1)
- H-G1. Admin file endpoints (
list_files,get_file,delete_file) skipvalidate_files_collection; repohead/get/list/deleteskipguard_collection. Onlycreate/updatevalidate. Safe today but one bad migration / restore tool can insert a row withcollection = "../../etc"and the nextget_filereads arbitrary host files. agent 6 F-FS-002.
External integrations (1)
- H-H1.
email::sendfrom scripts has no rate limit. TheEmailRateLimitertoken bucket exists but is wired only intousers_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 VALUEandpic login --token VALUEaccept secrets on argv — they land in shell history,ps aux, and/proc/<pid>/cmdline. Help text warns; the flag still works. Also, bothpic admins'read_password_from_stdinandpicloud admin reset-password's prompt advertise "no echo" but useread_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=offaccepted silently even whenPUBLIC_BASE_URLis HTTPS — agent 1.get_script/list_routes_for_scriptreturn 404 before authz → cross-app id enumeration — agent 2.routes:check/routes:matchtrust a body-suppliedapp_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_principalprincipal-cache is keyed by token-hash with no eviction on logout/password-change/deactivation — revocation lag bounded only by TTL — agent 3.content_typeaccepts CRLF → response-header injection / handler panic on download — agent 4.tracingtext-formatter is vulnerable to log forging via script-controlled queue/topic/collection names — agent 4.serde_json::from_strinjson::parseSDK and email-inbound has no recursion / size limits → stack overflow → process kill — agents 4 and 5.- No
max_modules_per_executionon 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-storeanywhere — 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_urlblocks 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)
- Drop cookie auth on
/api/v1/admin/*(or require synchronizer token). Closes C-1. - 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. - 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
- Login rate limit + global Argon2 semaphore (H-B1). Cheapest CPU-DoS in the system; pattern already exists in
EmailRateLimiter. - Install
engine.on_progressdeadline check so wall-clock budget actually cancels a runaway Rhai loop (H-C1). - Bind
aad = "secret:{app_id}:{name}"in the AES-GCM seal/open paths forsecrets,app_secrets, and email-trigger inbound secrets (H-D1). Fold into the v1.2 key-versioning pass already on the roadmap. - Wipe other sessions + revoke all API keys on self-password-change (H-E1). Mirror
cmd_reset_password's behavior. - Make
inbound_secretmandatory on email triggers; add per-(app_id, trigger_id)bad-signature token bucket (H-B2). - Mirror the
script.app_id == row.app_idcross-check frombuild_invoke_requestinto queue + HTTP dispatcher arms (H-F1, plus the Low sibling inbuild_http_request). - Rate-limit
email::sendfrom scripts by movingEmailRateLimiterintoEmailServiceImpland adding a per-message recipient cap (H-H1).
Tier 3 — Hardening sweep
- Per-app caps: trigger/route/cron count (H-B3), cron-scheduler
LIMIT 1000per tick (H-B4), explicitDefaultBodyLimitat router root + Caddyrequest_body { max_size }(H-B5). - Add per-execution SDK counters to
SdkCallCx(kv-ops, files, emails, http, pubsub publishes, invoke depth) — closes H-C2 and bounds invoke amplification. engine.disable_symbol("debug")— one-line, closes H-C3.- Scrub
ExecError::Runtimefor unauthenticated callers — closes H-C4 plus the principal-leak from join-panic strings. - Belt-and-suspenders collection validation in repo
head/get/list/deleteandvalidate_files_collectionat admin endpoints — closes H-G1. - Bootstrap sentinel: replace
count_active() > 0gate with a persistentbootstrap_donemarker — closes H-E2. - Reject
--password VALUE/--token VALUEargv flags; require--password -(stdin). Fixread_line→rpassword::prompt_passwordon every echo-claiming prompt. Closes H-J1.
Tier 4 — Quotas and observability (paves the way for v1.2)
- Per-app aggregate quotas: KV row count, files bytes, queue depth, queue count, docs collection size — agents 6 and 8.
- Absolute (hard-cap) session expiry on
admin_sessionsmirroringapp_user_sessions— agent 1. - Audit-log table for admin actions (
api_key_minted,user_promoted,app_deleted, etc.) — agent 10 I4. - Outbox GC sweep + circuit-breaker for HTTP-async retries — agent 8 M-1 / M-5.
AADmigration sweep +ZeroizeonMasterKeyand 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_iddiscipline. No Rhai-callable service-trait method acceptsapp_idas 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$Nbinds. Twoformat!-into-SQL patterns interpolate hardcodedconst &strcolumn lists only. The DSL builder (docs_repo::build_find_query) binds every user-supplied path segment and value viaQueryBuilder::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
Authorizationscrub. End-to-end trace from Rhaihttp::get→validate_url→policy.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, orFunction()in the dashboard; no external CDN<script>tags; CodeMirror does not evaluate Rhai as JS — agent 7. process::Commandconfined 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
unsafein executor-core / orchestrator-core; noeval/importreachable from scripts beyond the per-app module resolver; no file/process/network primitives registered) — agent 5. - Caddy admin is
admin offin dev, localhost-default in prod; not network-reachable — agent 9. /versionreturns only product/sdk/api/schema/wire versions +public_base_url; no build SHA, OS, or internal IP./healthzis literally"ok"— agent 10.
Per-agent index
Each file has full findings, recommendations, and traces:
- 01_authn_session.md — login flow, password hashing, session tokens, bootstrap, token transport
- 02_authz_isolation.md — capability gates,
SdkCallCx.app_id,resolve_app, dispatcher fan-out, slug-history - 03_crypto_secrets.md — master key, AES-GCM envelope, secrets/app-secrets/email-trigger seal/open, API keys
- 04_injection.md — SQL, JSONB, log injection, response-header injection, SMTP injection, regex DoS, command injection
- 05_sandbox_exec.md — Rhai engine limits, operation budgets, SDK surface, error propagation
- 06_files_pathtraversal.md — files API, on-disk layout, traversal guards, atomic writes, MIME handling
- 07_http_cors_csrf_xss.md — security headers, CORS, CSRF, SPA XSS, cookie/session flags
- 08_dos_resource.md — concurrency caps, rate limits, body limits, fan-out budgets, quota gaps
- 09_external_integrations.md — SSRF, HTTP-async, email inbound/outbound, Postgres TLS, CLI TLS
- 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_quotasschema 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.