style: cargo fmt across audit-2026-06-11 tier-3 changes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
195
SECURITY_AUDIT.md
Normal file
195
SECURITY_AUDIT.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 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/`](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](security_audit/01_authn_session.md) |
|
||||
| 02 | Authorization & cross-app isolation | 0 | 1 | 4 | 4 | 4 | [02_authz_isolation.md](security_audit/02_authz_isolation.md) |
|
||||
| 03 | Cryptography & secret handling | 0 | 2 | 5 | 3 | 6 | [03_crypto_secrets.md](security_audit/03_crypto_secrets.md) |
|
||||
| 04 | Injection & query safety | 0 | 0 | 5 | 4 | 9 | [04_injection.md](security_audit/04_injection.md) |
|
||||
| 05 | Rhai sandbox & script execution | 0 | 4 | 6 | 5 | 4 | [05_sandbox_exec.md](security_audit/05_sandbox_exec.md) |
|
||||
| 06 | File storage & path traversal | 0 | 2 | 4 | 4 | 2 | [06_files_pathtraversal.md](security_audit/06_files_pathtraversal.md) |
|
||||
| 07 | HTTP, CORS, CSRF & frontend XSS | **2** | 3 | 4 | 3 | 2 | [07_http_cors_csrf_xss.md](security_audit/07_http_cors_csrf_xss.md) |
|
||||
| 08 | DoS, rate limiting & resource exhaustion | 0 | 5 | 7 | 6 | 3 | [08_dos_resource.md](security_audit/08_dos_resource.md) |
|
||||
| 09 | External integrations (SSRF, webhooks) | 0 | 1 | 3 | 3 | 4 | [09_external_integrations.md](security_audit/09_external_integrations.md) |
|
||||
| 10 | Information disclosure, logging, CLI | 0 | 2 | 4 | 4 | 5 | [10_info_disclosure_cli.md](security_audit/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
|
||||
|
||||
### C-1. Same-origin CSRF on `/api/v1/admin/*` via `SameSite=Lax` cookie + co-hosted user-route HTML
|
||||
Agent 7 → [C07-01](security_audit/07_http_cors_csrf_xss.md#c07-01-critical--same-origin-csrf-on-admin-api-via-samesitelax-cookie--user-route-surface).
|
||||
|
||||
`POST /auth/login` ([crates/manager-core/src/auth_api.rs:229](crates/manager-core/src/auth_api.rs#L229)) sets `picloud_session=...; HttpOnly; Secure; SameSite=Lax`. The auth middleware ([auth_middleware.rs:91, 359](crates/manager-core/src/auth_middleware.rs#L91)) 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](security_audit/07_http_cors_csrf_xss.md#c07-02-critical--stored-xss-on-admin-origin-via-uploaded-files-served-inline-with-no-nosniff), agent 6 → [F-FS-001](security_audit/06_files_pathtraversal.md#f-fs-001--missing-mime-allowlist-enables-stored-xss-via-content-disposition-inline-on-the-admin-download-endpoint) (joint).
|
||||
|
||||
`GET /apps/{id}/files/{collection}/{file_id}` ([files_api.rs:130-164](crates/manager-core/src/files_api.rs#L130-L164)) 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](security_audit/07_http_cors_csrf_xss.md#h07-03-high--no-content-security-policy-anywhere) (`caddy/Caddyfile{,.prod}`, `docker/dashboard.Dockerfile:28`).
|
||||
- **H-A2.** No `X-Frame-Options` / `frame-ancestors` → dashboard is clickjackable — [agent 7 H07-04](security_audit/07_http_cors_csrf_xss.md#h07-04-high--no-x-frame-options--frame-ancestors-dashboard-clickjackable).
|
||||
- **H-A3.** No HSTS in production Caddyfile — [agent 7 H07-05](security_audit/07_http_cors_csrf_xss.md#h07-05-high--no-hsts-in-production-caddyfile).
|
||||
- **H-A4.** Dashboard stores bearer token in `localStorage` (XSS-readable) — [agent 10 H1](security_audit/10_info_disclosure_cli.md#h1--dashboard-bearer-token-stored-in-localstorage-xss-readable) / [agent 1 Medium](security_audit/01_authn_session.md) / [agent 7 L07-12](security_audit/07_http_cors_csrf_xss.md#l07-12-low--dashboard-token-in-localstorage). 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](security_audit/08_dos_resource.md#h-2--login-endpoint-has-no-rate-limit-argon2id-is-the-work-multiplier), confirmed by [agent 1](security_audit/01_authn_session.md).
|
||||
- **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](security_audit/08_dos_resource.md#h-3--email-inbound-webhook-has-no-ip-rate-limit-hmac-is-optional), [agent 9 F-EXT-M-001](security_audit/09_external_integrations.md#f-ext-m-001--email-inbound-webhook-accepts-unsigned-posts-when-inbound_secret-is-none).
|
||||
- **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](security_audit/08_dos_resource.md#h-4--no-per-app-cap-on-trigger--route--cron-registration).
|
||||
- **H-B4.** `cron_scheduler::tick` does `fetch_all` with no `LIMIT`, serially fires every due row inside one transaction. [agent 8 H-5](security_audit/08_dos_resource.md#h-5--cron-scheduler-tick-is-unbounded-fetch_all-one-slow-row-blocks-all).
|
||||
- **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](crates/orchestrator-core/src/api.rs#L219)) is explicit at 10 MiB. No Caddy `request_body { max_size }`. [agent 8 H-1](security_audit/08_dos_resource.md#h-1--no-axum-default-body-limit).
|
||||
|
||||
### 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](security_audit/05_sandbox_exec.md#f-se-h-01--tokiotimetimeout-over-joinhandle-does-not-cancel-a-spawn_blocking-task-runaway-script-keeps-its-os-thread-until-self-completion). **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](security_audit/05_sandbox_exec.md#f-se-h-02--sdk-service-calls-kvdocspubsubqueuesecretsfilesusersemailhttpinvoke-are-not-rate-limited-per-execution-one-invocation-can-issue-unbounded-io).
|
||||
- **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](security_audit/05_sandbox_exec.md#f-se-h-03--print-is-disabled-but-debug-is-not-rhais-debug-writes-to-stderr-by-default-and-the-comment-claims-both-are-disabled).
|
||||
- **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](security_audit/05_sandbox_exec.md#f-se-h-04--execerrorruntime-propagates-verbatim-rhaisdk-error-text-to-the-public-http-response-body-info-disclosure).
|
||||
|
||||
### 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](security_audit/03_crypto_secrets.md#high). **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](security_audit/03_crypto_secrets.md#master-key-sourcing-accepts-both-standard-and-url-safe-base64-but-the-dev-key-fallback-is-a-sha-256-of-a-public-string--making-dev-mode-trivially-decryptable-post-hoc).
|
||||
|
||||
### 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](crates/manager-core/src/admin_users_api.rs#L232-L241)). 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](security_audit/01_authn_session.md#dashboard-self-password-change-does-not-invalidate-other-live-sessions-or-api-keys).
|
||||
- **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](security_audit/01_authn_session.md#bearer-token-bootstrap-can-be-re-triggered-by-deleting-all-admins).
|
||||
|
||||
### Authorization defense-in-depth (1)
|
||||
- **H-F1.** The queue-trigger dispatcher ([dispatcher.rs:290-340](crates/manager-core/src/dispatcher.rs#L290-L340)) 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](security_audit/02_authz_isolation.md#queue-dispatcher-does-not-assert-scriptapp_id--claimedapp_id).
|
||||
|
||||
### 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](security_audit/06_files_pathtraversal.md#f-fs-002--admin-files-api-skips-collection-validation-underlying-headgetlistdelete-skip-the-fs-path-guard-too).
|
||||
|
||||
### 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](security_audit/09_external_integrations.md#f-ext-h-001--emailsend-from-scripts-is-not-rate-limited-operators-smtp-relay-is-a-free-amplifier).
|
||||
|
||||
### 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](security_audit/10_info_disclosure_cli.md#h2--pic-admins-createset---password-value-accepts-the-password-on-argv).
|
||||
|
||||
## 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
|
||||
4. **Login rate limit + global Argon2 semaphore** (H-B1). Cheapest CPU-DoS in the system; pattern already exists in `EmailRateLimiter`.
|
||||
5. **Install `engine.on_progress` deadline check** so wall-clock budget actually cancels a runaway Rhai loop (H-C1).
|
||||
6. **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.
|
||||
7. **Wipe other sessions + revoke all API keys on self-password-change** (H-E1). Mirror `cmd_reset_password`'s behavior.
|
||||
8. **Make `inbound_secret` mandatory on email triggers**; add per-`(app_id, trigger_id)` bad-signature token bucket (H-B2).
|
||||
9. **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`).
|
||||
10. **Rate-limit `email::send` from scripts** by moving `EmailRateLimiter` into `EmailServiceImpl` and adding a per-message recipient cap (H-H1).
|
||||
|
||||
### Tier 3 — Hardening sweep
|
||||
11. **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).
|
||||
12. **Add per-execution SDK counters** to `SdkCallCx` (kv-ops, files, emails, http, pubsub publishes, invoke depth) — closes H-C2 and bounds invoke amplification.
|
||||
13. **`engine.disable_symbol("debug")`** — one-line, closes H-C3.
|
||||
14. **Scrub `ExecError::Runtime` for unauthenticated callers** — closes H-C4 plus the principal-leak from join-panic strings.
|
||||
15. **Belt-and-suspenders collection validation** in repo `head`/`get`/`list`/`delete` and `validate_files_collection` at admin endpoints — closes H-G1.
|
||||
16. **Bootstrap sentinel**: replace `count_active() > 0` gate with a persistent `bootstrap_done` marker — closes H-E2.
|
||||
17. **Reject `--password VALUE` / `--token VALUE` argv flags**; require `--password -` (stdin). Fix `read_line` → `rpassword::prompt_password` on every echo-claiming prompt. Closes H-J1.
|
||||
|
||||
### Tier 4 — Quotas and observability (paves the way for v1.2)
|
||||
18. **Per-app aggregate quotas**: KV row count, files bytes, queue depth, queue count, docs collection size — agents 6 and 8.
|
||||
19. **Absolute (hard-cap) session expiry** on `admin_sessions` mirroring `app_user_sessions` — agent 1.
|
||||
20. **Audit-log table** for admin actions (`api_key_minted`, `user_promoted`, `app_deleted`, etc.) — agent 10 I4.
|
||||
21. **Outbox GC sweep** + circuit-breaker for HTTP-async retries — agent 8 M-1 / M-5.
|
||||
22. **`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::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`, 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](security_audit/01_authn_session.md) — login flow, password hashing, session tokens, bootstrap, token transport
|
||||
2. [02_authz_isolation.md](security_audit/02_authz_isolation.md) — capability gates, `SdkCallCx.app_id`, `resolve_app`, dispatcher fan-out, slug-history
|
||||
3. [03_crypto_secrets.md](security_audit/03_crypto_secrets.md) — master key, AES-GCM envelope, secrets/app-secrets/email-trigger seal/open, API keys
|
||||
4. [04_injection.md](security_audit/04_injection.md) — SQL, JSONB, log injection, response-header injection, SMTP injection, regex DoS, command injection
|
||||
5. [05_sandbox_exec.md](security_audit/05_sandbox_exec.md) — Rhai engine limits, operation budgets, SDK surface, error propagation
|
||||
6. [06_files_pathtraversal.md](security_audit/06_files_pathtraversal.md) — files API, on-disk layout, traversal guards, atomic writes, MIME handling
|
||||
7. [07_http_cors_csrf_xss.md](security_audit/07_http_cors_csrf_xss.md) — security headers, CORS, CSRF, SPA XSS, cookie/session flags
|
||||
8. [08_dos_resource.md](security_audit/08_dos_resource.md) — concurrency caps, rate limits, body limits, fan-out budgets, quota gaps
|
||||
9. [09_external_integrations.md](security_audit/09_external_integrations.md) — SSRF, HTTP-async, email inbound/outbound, Postgres TLS, CLI TLS
|
||||
10. [10_info_disclosure_cli.md](security_audit/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.
|
||||
@@ -182,7 +182,9 @@ pub fn email_inbound_router(state: EmailInboundState) -> Router {
|
||||
"/email-inbound/{app_id}/{trigger_id}",
|
||||
post(receive_inbound_email),
|
||||
)
|
||||
.layer(axum::extract::DefaultBodyLimit::max(INBOUND_BODY_LIMIT_BYTES))
|
||||
.layer(axum::extract::DefaultBodyLimit::max(
|
||||
INBOUND_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
||||
@@ -93,8 +93,7 @@ async fn list_files(
|
||||
// traversal-shaped collection would otherwise only be caught by the
|
||||
// repo's `guard_collection` as an opaque 500. Validate here for a
|
||||
// clean 422 and as the outer layer of the belt-and-suspenders guard.
|
||||
validate_files_collection(&q.collection)
|
||||
.map_err(|e| FilesApiError::Invalid(e.to_string()))?;
|
||||
validate_files_collection(&q.collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?;
|
||||
let page = s
|
||||
.files
|
||||
.list(
|
||||
|
||||
@@ -503,8 +503,7 @@ pub async fn build_app(
|
||||
// revocation, so an evict on the revocation side is visible to the
|
||||
// middleware's resolve side. A per-state cache would let a revoked
|
||||
// principal keep authenticating from the middleware's own copy.
|
||||
let principal_cache =
|
||||
Arc::new(picloud_manager_core::auth_middleware::PrincipalCache::new());
|
||||
let principal_cache = Arc::new(picloud_manager_core::auth_middleware::PrincipalCache::new());
|
||||
let auth_state = AuthState {
|
||||
users: auth.users.clone(),
|
||||
sessions: auth.sessions.clone(),
|
||||
|
||||
107
security_audit/01_authn_session.md
Normal file
107
security_audit/01_authn_session.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# AuthN, Session & Token Lifecycle
|
||||
|
||||
Audit date: 2026-06-11
|
||||
Scope: login flow, password hashing, session token lifecycle, bootstrap, token transport.
|
||||
|
||||
Methodology: read `crates/manager-core/src/{auth,auth_api,auth_middleware,auth_bootstrap,admin_session_repo,admin_user_repo,admin_users_api}.rs`, `crates/picloud/src/main.rs` reset-password CLI, `crates/manager-core/migrations/0004_admin_auth.sql`, `dashboard/src/lib/{auth,api}.ts`. Cross-checked against `AUDIT.md` to avoid duplicating remediated findings. `F-S-006`, `F-S-007`, `F-S-011`, `F-S-009` from the prior audit overlap this scope and are noted briefly but not re-reported in full; everything below is either new or a sharper restatement.
|
||||
|
||||
## High
|
||||
|
||||
### Dashboard self-password-change does not invalidate other live sessions or API keys
|
||||
- **Where**: `crates/manager-core/src/admin_users_api.rs:232-241`
|
||||
- **What**: The `PATCH /admin/admins/{id}` password-change branch updates `password_hash` and immediately comments: *"Best practice: rotating your own password should still keep your session alive, so we don't wipe sessions here."* — but it never wipes **other** sessions or any `api_keys` rows either. By contrast, `cmd_reset_password` in `crates/picloud/src/main.rs:189-200` *does* call `sessions.delete_for_user`, and the deactivation branch (`admin_users_api.rs:296-310`) wipes sessions + API keys. Only the in-product password change is silent.
|
||||
- **Impact**: An admin who suspects compromise and rotates their password through the dashboard (the natural reaction) leaves every other live session and every minted API key valid until they hit individual TTL expiry (`admin_sessions.expires_at` defaults to 24h sliding, API keys until manually revoked). A stolen `picloud_session` token or `pic_…` bearer continues to authenticate. This is the classic "credential rotation that doesn't rotate credentials" pitfall — and it directly contradicts blueprint §11.4's stated invariant that a password change invalidates all other sessions for that user (memory `admin_auth_design`).
|
||||
- **Recommendation**: After `update_password_hash`, fetch the caller's current `token_hash` from the request, call `sessions.delete_for_user(id)`, **then** re-`create` the caller's session (or accept the sign-out and 401 the response). Also call `keys.expire_all_for_user(id)` and document the consequence in the UI ("Changing your password will revoke all of your API keys"). The current behavior is silently more dangerous than either alternative.
|
||||
|
||||
### Bearer-token Bootstrap can be re-triggered by deleting all admins
|
||||
- **Where**: `crates/manager-core/src/auth_bootstrap.rs:89` (count gate) + `crates/manager-core/src/admin_users_api.rs:340-355` (`delete_admin`)
|
||||
- **What**: `bootstrap_first_admin_with` short-circuits when `count_active() > 0`. The guard counter is whatever `count_active()` reports — checking that repo: it counts every row (including `is_active=false`), so an empty `admin_users` table re-enables bootstrap on next process start. That alone is fine, **but** `delete_admin` performs a hard DELETE (`users.delete(id)`, no soft-delete). The "last-active-admin" guard only blocks **deactivation/deletion of the only `is_active=true` row**. If an operator deactivates all admins (impossible — last-active guard) OR an attacker with `InstanceManageUsers` can wipe rows after escalation, the bootstrap env vars become live again on next restart. Combined with the fact that `PICLOUD_ADMIN_USERNAME` / `PICLOUD_ADMIN_PASSWORD_HASH` are still set in nearly every production compose file ("for the initial install"), this is a re-takeover path.
|
||||
- **Impact**: An attacker who can either (a) gain `InstanceManageUsers` briefly and delete all admin rows, or (b) drop `admin_users` rows via SQL (separate compromise) — followed by a restart (cron, deploy, OOM) — re-seeds the env-supplied admin. The env-var leak risk (leaked from CI, `.env` file, `docker inspect`, `/proc/<pid>/environ`) becomes a perpetual liability rather than a one-shot.
|
||||
- **Recommendation**: Either (1) refuse bootstrap if **any** row has ever existed (track with a `bootstrap_done` row in `instance_state` or a sentinel boolean in `admin_users`), or (2) document and enforce that the bootstrap env vars are unset after first start (start-up warning when set; harden compose templates). Option (1) is the safer default; the env var is by design a "fresh install" hatch.
|
||||
|
||||
## Medium
|
||||
|
||||
### Admin sessions have no absolute (hard-cap) expiry
|
||||
- **Where**: `crates/manager-core/src/admin_session_repo.rs:23-30`, `crates/manager-core/migrations/0004_admin_auth.sql:24-30`, `auth_middleware.rs:245-249`
|
||||
- **What**: `admin_sessions` has `expires_at` (sliding) and `last_used_at`, no `absolute_expires_at`. Every authenticated request bumps `expires_at = NOW() + ttl`. A stolen token that's exercised at least once per `PICLOUD_SESSION_TTL_HOURS` (default 24h) never expires. Contrast with `app_user_sessions` (v1.1.8) which has both `expires_at` and `absolute_expires_at` — the admin surface is the **more** sensitive of the two and has weaker session hygiene.
|
||||
- **Impact**: Long-lived token theft (e.g., a stale localStorage entry on a shared workstation, a leaked support session) is materially harder to detect; nothing forces a re-auth.
|
||||
- **Recommendation**: Add `absolute_expires_at TIMESTAMPTZ NOT NULL` (default `NOW() + 30d` or `NOW() + 2*ttl`) populated at `create`, never touched by `touch`. Filter `WHERE absolute_expires_at > NOW()` in `lookup`. Mirrors the shape of `app_user_sessions`.
|
||||
|
||||
### Admin bearer token is stored in `localStorage` — XSS = account takeover with no recovery window
|
||||
- **Where**: `dashboard/src/lib/auth.ts:22-43`, `dashboard/src/lib/api.ts:493-495`
|
||||
- **What**: `setSession()` writes the raw bearer token to `localStorage['picloud.admin.token']`. Any XSS on the dashboard origin trivially exfiltrates it. The HttpOnly cookie is also set, so the **cookie path** is XSS-safe, but the `localStorage` echo defeats that defense — the dashboard's own auth call path reads from localStorage and injects `Authorization: Bearer …`, which means any XSS-injected `fetch` works identically. Adding `Strict-Transport-Security` and a CSP nonce wouldn't help if a script-tag injection lands.
|
||||
- **Impact**: An attacker with one stored-XSS primitive in any admin-rendered surface (Markdown in script descriptions, app names, route paths in admin views, audit log fields, etc.) elevates to durable session theft. Combined with the High above, the stolen token survives a password change.
|
||||
- **Recommendation**: Drop the localStorage echo and rely on the HttpOnly cookie alone for browser sessions. The cookie already round-trips on every request (Path=/). Keep `Authorization: Bearer …` injection only for non-browser CLI clients that explicitly call the login API and store the token themselves. If localStorage must stay for SPA UX reasons, at minimum: rotate the token on first request after detection of a CSP violation event, and ship a strict CSP (`default-src 'self'; script-src 'self'`) so injection requires a CSP bypass.
|
||||
|
||||
### Password change does not enforce current-password re-verification
|
||||
- **Where**: `crates/manager-core/src/admin_users_api.rs:204-241`
|
||||
- **What**: `PATCH /admins/{id}` accepts a new password without any "current password" or step-up auth. Anyone with a live session token can change the account's password to one of their choosing. Combined with the long sliding-TTL session (no absolute cap), a single hijacked session escalates to permanent ownership.
|
||||
- **Impact**: Lost / borrowed laptop, brief session theft, XSS — all become permanent takeover because the attacker can reset the password and lock the legit user out (subject to the High finding: their existing session even survives the password change).
|
||||
- **Recommendation**: Require the request body to include `current_password` when the patch carries a `password` field for the caller's own id. Verify with `verify_password` on `spawn_blocking`. For admin-on-admin password change, require recent step-up (e.g., the caller re-supplied their password in the last 5 minutes).
|
||||
|
||||
### `PICLOUD_COOKIE_SECURE` defaults to ON but is silently disablable; no warning emitted
|
||||
- **Where**: `crates/manager-core/src/auth_api.rs:221-227`
|
||||
- **What**: `build_cookie` honors `PICLOUD_COOKIE_SECURE=0|false|no|off`. There is no startup log surfacing the resolved value, no refusal when combined with a public `PICLOUD_PUBLIC_BASE_URL`. An operator running prod over HTTP-only mode (reverse proxy misconfig, internal-only deploy, dev-on-prod leak — see F-S-009) silently issues plain-HTTP cookies.
|
||||
- **Impact**: Network-adjacent attackers (LAN, MITM, hijacked Wi-Fi) capture admin session tokens off the wire.
|
||||
- **Recommendation**: Refuse `PICLOUD_COOKIE_SECURE=off` when `PICLOUD_PUBLIC_BASE_URL` starts with `https://` or contains a non-localhost host. At minimum, log an `error!` on every login that resolves a non-Secure cookie.
|
||||
|
||||
### CLI `reset-password` reads stdin with terminal echo (no `rpassword`)
|
||||
- **Where**: `crates/picloud/src/main.rs:203-216`
|
||||
- **What**: `prompt_password_from_stdin()` prints "will be read from stdin, no echo" — but it just calls `std::io::stdin().lock().read_line(&mut line)`. On a real TTY, that echoes characters. The "no echo" claim is false. Also: the raw password sits in `line` (a `String` allocated on the heap) with no zeroization on drop.
|
||||
- **Impact**: Shoulder-surfing during recovery; password ends up in shell history if the operator piped via `read -p` wrapper; raw password lives in process heap after use (forensic concern).
|
||||
- **Recommendation**: Use `rpassword::read_password` (or `termion`) for terminal echo suppression. Wrap the password in `secrecy::SecretString` so it zeroizes on drop. Update the prompt text to reflect reality if the simple-stdin path is kept for piped CI use.
|
||||
|
||||
### Login surface has no rate limit, lockout, or captcha (recap of F-S-007)
|
||||
- **Where**: `crates/manager-core/src/auth_api.rs:74-110`
|
||||
- **What**: AUDIT.md F-S-007 already records this — `POST /auth/login` Argon2-verifies every attempt, no per-IP throttle, no per-username lockout. Re-flagged here only because it's *the* missing control for a self-host admin surface. Defense is "stand up Caddy rate-limit in front" which is documented nowhere user-visible.
|
||||
- **Recommendation**: As in F-S-007: per-IP token bucket and per-username sliding-window lockout. At minimum, document the requirement explicitly in `serverless_cloud_blueprint.md` deployment notes.
|
||||
|
||||
## Low
|
||||
|
||||
### `validate_password` only enforces a length minimum (8); no character class, no breached-password check, no maximum
|
||||
- **Where**: `crates/manager-core/src/admin_users_api.rs:35,382-389`
|
||||
- **What**: 8 chars is below current NIST guidance for unrestricted-rate verifiers (NIST SP 800-63B suggests ≥8 with breached-password check OR rate-limit). Argon2 absorbs arbitrarily long passwords, so no hard upper bound = a 64MB password POST can complete the Argon2 (memory-bounded). Combined with no login rate limit, a long-password DoS is a small lever.
|
||||
- **Recommendation**: Raise minimum to 12, add a maximum (e.g., 1024 chars) to bound input cost, and optionally wire `zxcvbn` for strength feedback in the dashboard.
|
||||
|
||||
### Logout reveals nothing about whether the token was valid
|
||||
- **Where**: `crates/manager-core/src/auth_api.rs:169-187`
|
||||
- **What**: Logout always returns 204 regardless of whether the supplied token matched a row. This is correct (idempotent) — the only observation is that an attacker can use `/auth/logout` as a free oracle to test whether a candidate token *exists* by, say, timing the DELETE response. Postgres DELETE timing is data-dependent but the variance is small. Listing as Info-grade because the practical impact is negligible (32-byte random tokens are unguessable).
|
||||
- **Recommendation**: None required; noted for completeness.
|
||||
|
||||
### `me` re-reads the `admin_users` row on every call; no caching of fresh username
|
||||
- **Where**: `crates/manager-core/src/auth_api.rs:189-210`
|
||||
- **What**: Not a security issue. Cross-reference only because the prior audit's F-P-009 PrincipalCache might leak stale role data through `/me` — verified: `/me` deliberately bypasses the cache and re-fetches, so role changes show up promptly. Behavior is correct.
|
||||
|
||||
### `extract_token_for_logout` duplicates middleware extraction logic
|
||||
- **Where**: `crates/manager-core/src/auth_api.rs:234-261` vs `crates/manager-core/src/auth_middleware.rs:362-386`
|
||||
- **What**: Two copies of the same Bearer / cookie extraction. Cosmetic risk of drift; one might add a CSRF check or token-format validation and forget the other.
|
||||
- **Recommendation**: Extract to a shared function in `auth.rs`.
|
||||
|
||||
## Info
|
||||
|
||||
### Argon2 parameters are OWASP defaults and salt is per-hash from `OsRng`
|
||||
- **Where**: `crates/manager-core/src/auth.rs:41-45`
|
||||
- Argon2id, `Argon2::default()` (v0x13, m=19456 KiB, t=2, p=1, 32-byte hash), salt from `argon2::password_hash::rand_core::OsRng`. `argon2 = "0.5"`, `rand = "0.8"`. Verify path is the library's `verify_password` (constant-time on hash bytes). PHC encoding pins parameters per-hash so a future raise of defaults doesn't break existing rows. No findings here; recorded for the audit trail.
|
||||
|
||||
### Session token entropy and storage shape are sound
|
||||
- **Where**: `crates/manager-core/src/auth.rs:80-95`, `migrations/0004_admin_auth.sql:24-30`
|
||||
- 32 random bytes from `rand::rngs::OsRng` (256 bits, far above the 128-bit minimum) → base64-url-no-pad → SHA-256 hex stored as PK on `admin_sessions.token_hash`. Lookup is index-equality so timing on the DB side is bounded by the index path, not by token contents (the SHA-256 step happens before the DB call and is constant-time over the input). Raw token never persists; logout/touch/delete all keyed on hash. Token-equals-bearer is by design (memory `admin_auth_design`). No findings.
|
||||
|
||||
### Login is timing-flat against username enumeration
|
||||
- **Where**: `crates/manager-core/src/auth.rs:35-36`, `auth_api.rs:79-110`
|
||||
- The `TIMING_FLAT_DUMMY_HASH` constant is verified against on missing-user lookups, so the wall-clock cost of "bad username" matches "bad password". `spawn_blocking` is used for the verify (F-P-002 remediation). No timing oracle on enumeration via login.
|
||||
|
||||
### Bootstrap idempotency race is safe by accident
|
||||
- **Where**: `crates/manager-core/src/auth_bootstrap.rs:89-128`
|
||||
- `count_active() > 0` check is not atomic with `repo.create`, but `admin_users.username` is `UNIQUE`, so concurrent boots can't double-seed. The second loser surfaces a `Repo` error rather than silently skipping — operator-visible. Acceptable.
|
||||
|
||||
### MFA / 2FA not present
|
||||
- Out of MVP per blueprint and consistent with prior decisions (single-user solo-dev target). Worth tracking as an explicit "deferred until v1.2 multi-admin scenarios" item.
|
||||
|
||||
## Out of scope (cross-references)
|
||||
- **F-S-006** (API-key Argon2 amplifier), **F-S-007** (no login rate-limit), **F-S-009** (DEV_MODE deterministic key), **F-S-011** (no `revoked_at` on `admin_sessions`) — all covered in `AUDIT.md`. Remediation state per memory `audit_2026_06_07_remediation`: F-S-006 and F-S-007 mitigated (cap + spawn_blocking); F-S-009 and F-S-011 outstanding.
|
||||
- API-key authorization scopes — covered by agent 2 (authorization).
|
||||
- Encryption-at-rest and key rotation (`PICLOUD_SECRET_KEY`) — covered by agent 3 (crypto).
|
||||
- CSRF on cookie-auth endpoints — partially in this report (SameSite=Lax decision); cross-origin scenarios covered by agent 6 (network/CSRF).
|
||||
- Login DoS via Argon2 memory — covered by agent 7 (DoS).
|
||||
- Authorization headers in tower-http TraceLayer logs — covered by agent 10 (information disclosure).
|
||||
97
security_audit/02_authz_isolation.md
Normal file
97
security_audit/02_authz_isolation.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Authorization & Cross-App Isolation
|
||||
|
||||
Audit date: 2026-06-11
|
||||
Scope: `Capability` machinery, `authz::{can, require, script_gate}`, `resolve_app`, per-handler capability gating, SDK service `app_id` discipline, dispatcher fan-out, dashboard-supplied app ids, slug-history (`redirected`) path, public-script anonymous semantics.
|
||||
|
||||
## Method (what I actually traced)
|
||||
|
||||
Two SDK paths traced from Rhai surface to SQL bind:
|
||||
|
||||
1. `kv::collection("x").set(k, v)` — bridge in `crates/executor-core/src/sdk/kv.rs:45` captures `Arc<SdkCallCx>` set at `crates/executor-core/src/engine.rs:218-228` (`app_id: req.app_id`), where `req.app_id = script.app_id` from `crates/orchestrator-core/src/api.rs:123` (the resolver's row). The `KvService::set` impl at `crates/manager-core/src/kv_service.rs:130-187` calls `self.repo.set(cx.app_id, …)` which lands at `crates/manager-core/src/kv_repo.rs:113-127` with `WHERE app_id = $1 AND collection = $2 AND key = $3`. **No script-controlled `app_id` enters the call chain.** ✅
|
||||
2. `dead_letters::replay(id)` — bridge at `crates/executor-core/src/sdk/dead_letters.rs`, impl `crates/manager-core/src/dead_letter_service.rs:62-94`. Capability check (`AppDeadLetterManage(cx.app_id)`) runs first; the row is loaded by id, then `row.app_id != cx.app_id` short-circuits to `NotFound` (line 67-71). **Cross-app id probing is timing-flat with intra-app not-found.** ✅
|
||||
|
||||
Two admin handlers traced from URL → resolve → require → repo:
|
||||
|
||||
3. `DELETE /admin/apps/{slug}/triggers/{trigger_id}` — `crates/manager-core/src/triggers_api.rs:659-687`. `resolve_app` (line 664) → `require(Capability::AppManageTriggers(app_id))` against the RESOLVED app id (line 669) → load row → reject if `trigger.app_id != app_id` (line 680). No TOCTOU between resolve and authz; both run on the same `app_id` value. ✅
|
||||
4. `POST /admin/apps/{slug}/dead-letters/{id}/replay` — `crates/manager-core/src/dead_letters_api.rs:175-188`. `resolve_app` → admin-synth `SdkCallCx` carrying the resolved `app_id` + principal (line 208-222) → `service.replay(&cx, dl_id)`. Authz lives inside the service (above). ✅
|
||||
|
||||
Service-trait sweep: every `*Service` trait in `crates/shared/src/{kv,docs,files,secrets,email,http,queue,pubsub,dead_letters,invoke,events}.rs` takes `&SdkCallCx` and **does NOT** accept `app_id` as a side argument. The only methods with an explicit `app_id` parameter are admin-mediated `UsersService::{verify_session_for_realtime, admin_*, list_invitations, revoke_invitation}` — called from the manager-core HTTP layer or the realtime authority, never from a script bridge.
|
||||
|
||||
## High
|
||||
|
||||
### Queue dispatcher does not assert `script.app_id == claimed.app_id`
|
||||
- **Where**: `crates/manager-core/src/dispatcher.rs:290-340`
|
||||
- **What**: `dispatch_one_queue` claims a queue message scoped to `consumer.app_id`, resolves `script` by `consumer.script_id` (line 290), and builds an `ExecRequest` with `app_id: claimed.app_id` (line 334) and `script_id: consumer.script_id` — but never cross-checks that `script.app_id == claimed.app_id`. The analogous `build_invoke_request` does (line 752: `if script.app_id != row.app_id { return Err(...) }`), and the cross-app invoke guard in `invoke_service.rs:83-85` does too. Trigger registration (`triggers_api.rs:280-303 validate_trigger_target`) blocks same-app divergence at create time, so today this is guarded by a write-path invariant only. If a trigger row is hand-edited, restored from a partial backup, or (future) script-move-between-apps is added, the dispatcher would happily execute a script from app B under app A's `SdkCallCx` — every `kv::*`, `docs::*`, `files::*`, `secrets::*`, `pubsub::*`, `email::*` SDK call would scope to **app A**. The script's source belongs to app B but reads/writes app A's data.
|
||||
- **Impact**: Latent cross-app data read/write if any of the above ever holds. Defense-in-depth is missing on the single most security-critical fan-out path. The same gap is absent in the (sibling) `build_invoke_request` precisely because it was added there explicitly — the queue arm just wasn't audited the same way.
|
||||
- **Recommendation**: Mirror the invoke guard. After `self.scripts.get(consumer.script_id)` succeeds, check `if script.app_id != consumer.app_id { dead_letter the message + tracing::error!("queue trigger script app mismatch"); return; }`. Same fix in `build_http_request` (`dispatcher.rs:651-718`) — its `script.app_id` is never compared to `row.app_id`, only used.
|
||||
|
||||
## Medium
|
||||
|
||||
### `get_script` and `list_routes_for_script` return 404 before authz — cross-app id enumeration
|
||||
- **Where**: `crates/manager-core/src/api.rs:184-197` (`get_script`), `crates/manager-core/src/route_admin.rs:141-158` (`list_routes`)
|
||||
- **What**: Both handlers load the resource by id and 404 if absent, **before** the authz check. A `Member` of app A who guesses a `script_id` belonging to app B observes `404` for non-existent ids and `403` for ids that exist in app B (authz denies on `AppRead(script.app_id)`). The discriminator confirms script-id existence across the whole instance. Same gap on the routes-for-script list. Note this contrasts with `apps_api::create_script` (line 204-213), which intentionally inverts the order to avoid app-existence enumeration ("checking authz first means a Member trying to create against an unknown app gets 403 (no enumeration of app existence)").
|
||||
- **Impact**: Low-grade information leak — a malicious member can map out script-id space across apps. `ScriptId`s are UUIDv4, so brute-force enumeration is impractical, but ids leak through logs, error messages, and dashboard URLs (a former member with a stale screenshot can re-confirm validity post-revocation).
|
||||
- **Recommendation**: Either (a) collapse `NotFound` and `Forbidden` to the same response for these two handlers, or (b) flip the order: do a *cheap* authz pre-check (you can't, because you need the resource's app_id) — so (a) is the practical fix. Same shape as the membership-on-other-app pattern already in use elsewhere.
|
||||
|
||||
### Capability check on `routes:check` / `routes:match` trusts a body-supplied `app_id`
|
||||
- **Where**: `crates/manager-core/src/route_admin.rs:261-329`
|
||||
- **What**: Both handlers pull `app_id` from the JSON body (`CheckRouteRequest` / `MatchRouteRequest`) and authz against it. Because authz blocks a Member of app A from `AppRead(B)`, this is currently safe at runtime. The structural risk: the dashboard sends `{ app_id: appId, … }` from `dashboard/src/lib/api.ts:709-718` and any future code path that strips authz (e.g., a "public health-check" wrapper, a new internal caller) would have no second line of defense — the resource (app) isn't path-bound and isn't loaded.
|
||||
- **Impact**: Defense-in-depth gap. Inconsistent with every other recently-refactored handler (`queues_api`, `files_api`, `secrets_api`, `topics_api`, `dead_letters_api`, `triggers_api`) which path-bind the slug and load the app row before authz.
|
||||
- **Recommendation**: Either move to `POST /admin/apps/{slug}/routes:check` (path-bind the slug, then `resolve_app`), or load `state.apps.get_by_id(input.app_id)` and fail closed on `None` before authz. Both restore the resolve-then-authz invariant.
|
||||
|
||||
### Cron / trigger fan-out runs forever under a deactivated user's principal — `PrincipalResolver` rejects but the dispatch arm has no recovery
|
||||
- **Where**: `crates/manager-core/src/principal_resolver.rs:42-61`, `crates/manager-core/src/dispatcher.rs:314-318`
|
||||
- **What**: When the registrant is deactivated (`is_active = false`), `PrincipalResolver::resolve` returns `Inactive(user_id)` → the dispatcher surfaces `ResolveTrigger(...)` and the dispatch loop logs and moves on. The outbox row stays claimed-then-rescheduled forever (or the cron row keeps inserting). There's no "disable the trigger when its owner is gone" path. Per `dispatcher.rs:316`, the resolver error short-circuits the dispatch but doesn't disable the trigger. This means a deactivated former admin's cron jobs continue to *attempt* execution indefinitely, building outbox backpressure.
|
||||
- **Impact**: Not a direct authz break (the script never runs because the principal isn't resolvable), but: (a) operational noise; (b) if the registrant is **reactivated later** with reduced privileges, every backed-up cron tick suddenly fires under their new (possibly weaker) identity at once; (c) if an attacker who briefly held `InstanceManageUsers` registered crons under a sock-puppet account and was then locked out, the crons remain dormant and re-arm on any future user revival with the same id.
|
||||
- **Recommendation**: On `Inactive` from the resolver, mark the trigger `enabled = false` (with an audit note) and dead-letter any in-flight outbox row that targets it. Mirrors the script-missing arm at `dispatcher.rs:292-310` which already dead-letters cleanly.
|
||||
|
||||
### Public/anonymous scripts can call same-app `AppDeadLetterManage` only because the service explicitly rejects `principal: None`
|
||||
- **Where**: `crates/manager-core/src/dead_letter_service.rs:40-51`
|
||||
- **What**: For dead letters specifically, the service handles "anonymous caller" with a hard `Err(Forbidden)` rather than `script_gate`'s default "skip the check for `cx.principal == None`." That's the correct intent (managing dead letters is an admin act). The risk is that **the convention for every other stateful service (`kv`, `docs`, `files`, `secrets`, `pubsub`, `email`, `users`) is the OPPOSITE**: anonymous public-HTTP scripts skip the capability check entirely (see `authz::script_gate` at `authz.rs:327-342`). A public, anonymous-callable HTTP script in app A can write to `kv::collection("x")`, send email via `email::send`, publish on `pubsub::*`, mutate app-users via `users::*`, etc. — all under "script-as-gate" semantics. This is by design (the author owns the script and decides whether to expose it via a public route), but it means a single misconfigured route exposes powerful capabilities. There's no global toggle to require auth on a public route.
|
||||
- **Impact**: Documented design, but the boundary is one config mistake away from anonymous-internet → email send → SMTP-amplification-of-app-secrets. The "script is the gate" guarantee depends on the script author's discipline.
|
||||
- **Recommendation**: Two improvements — (a) document this loudly in the route-create endpoint response ("this route is anonymous-callable; the script you bound holds capabilities X, Y, Z and will execute them un-gated"); (b) consider a per-route `require_principal: bool` toggle so app admins can flip a route to "no anonymous calls allowed" without rewriting the script. Defers cleanly to v1.2.
|
||||
|
||||
## Low
|
||||
|
||||
### `build_http_request` does not cross-check `script.app_id == row.app_id`
|
||||
- **Where**: `crates/manager-core/src/dispatcher.rs:651-718`
|
||||
- **What**: Same shape as the High above. The HTTP outbox arm uses `row.app_id` to build the `ExecRequest` (line 686) and `script` (resolved by `row.script_id` at line 660) is trusted to live in the same app. Routes are guaranteed same-app by `route_admin.rs:179` (`app_id = script.app_id`), so today no row violates the invariant. Listing as Low (not High) because the write-path is tighter for HTTP than for queue triggers — there's no `validate_trigger_target` equivalent because routes carry their own `app_id`. Still worth the belt-and-suspenders check.
|
||||
- **Recommendation**: One line: `if script.app_id != row.app_id { return Err(DispatcherError::ResolveTrigger("http row app mismatch")); }` after the script load.
|
||||
|
||||
### Email-inbound writes outbox with URL `app_id`, not `target.app_id`
|
||||
- **Where**: `crates/manager-core/src/email_inbound_api.rs:144-189`
|
||||
- **What**: After cross-checking `target.app_id != app_id` at line 145, the outbox insert at line 177 uses the URL-derived `app_id`. They're equal because of the check, but `target.app_id` is the canonical source of truth (it survives even if the cross-check were ever relaxed). One-character fix; matters only if the cross-check ever gets refactored.
|
||||
- **Recommendation**: `app_id: target.app_id` in the `NewOutboxRow`.
|
||||
|
||||
### `AppLookup.redirected` flag is consumed only in `apps_api::get_app`
|
||||
- **Where**: `crates/manager-core/src/app_repo.rs:25-48`, `crates/manager-core/src/apps_api.rs:213-217`
|
||||
- **What**: Every other consumer (`triggers_api`, `topics_api`, `files_api`, `secrets_api`, `queues_api`, `dead_letters_api`, `users_admin_api`, `app_members_api`) silently discards the flag via `.map(|l| l.app.id)` / `.map(|l| l.app)`. That's fine — historical slugs are valid identifiers for the canonical app. No authz check is bypassed; the resolved `app.id` carries the same authority as the live-slug path. I traced this exhaustively; listing as **Info** to confirm no privilege escalation via redirect, but moving to Low because the convention is implicit (each consumer must remember to discard, not skip a check). A clearer API might be `resolve_app` returning `(AppId, Option<String> /* redirected_from */)` so the call site can never accidentally use the slug.
|
||||
- **Recommendation**: Cosmetic. Consider tightening the type.
|
||||
|
||||
### `RESERVED_MODULE_NAMES` is a hard-coded list — drift risk on future SDK module additions
|
||||
- **Where**: `crates/manager-core/src/api.rs:261-280`
|
||||
- **What**: New SDK modules added to executor-core (queue is the most recent) must be remembered to be added to this list. It's not an authz issue per se; the shadow risk is that a user-supplied module of the same name resolves the user copy. Not load-bearing for cross-app isolation (each app's modules are app-scoped anyway), but worth a comment pointing at `crates/executor-core/src/sdk/mod.rs` so the next contributor edits both.
|
||||
- **Recommendation**: Centralize the reserved-name list in `picloud-shared` next to the SDK trait registrations.
|
||||
|
||||
## Info
|
||||
|
||||
### SDK trait surface is clean on `app_id` discipline
|
||||
- Every `*Service` trait method that a Rhai bridge can invoke takes `&SdkCallCx` and never accepts `app_id` as a parameter. Verified by grep across `crates/shared/src/{kv,docs,files,secrets,email,http,queue,pubsub,dead_letters,invoke,events}.rs`. The only `app_id`-parameter methods are admin-mediated (`UsersService::admin_*`, `verify_session_for_realtime`, `list_invitations`, `revoke_invitation`) and live under the HTTP admin layer, not the Rhai bridge.
|
||||
|
||||
### `Capability` enum is exhaustively matched
|
||||
- `Capability::app_id()` and `Capability::required_scope()` use full match arms with no default branch, so adding a new variant forces every call site to be updated. Test `capability_required_scope_mapping_is_complete` at `authz.rs:868-887` plus compiler enforcement makes this a self-healing surface.
|
||||
|
||||
### Bound API keys cannot escape their app
|
||||
- `binding_allows` at `authz.rs:449-461` denies instance-scoped capabilities for bound keys outright and requires `target_app == bound_app` for app-scoped ones. Mint-handler also rejects the combination upfront; the runtime layer is defense-in-depth. Tested at `authz.rs:730-767`.
|
||||
|
||||
### Anonymous public scripts are visibly distinguishable in `script_gate`
|
||||
- `authz::script_gate` at `authz.rs:327-342` explicitly distinguishes `cx.principal.is_none()` ("skip the check, script-as-gate") from authenticated callers. Recorded for clarity; the trade-off discussed in the Medium "Public/anonymous scripts" item above.
|
||||
|
||||
### `default` slug has no runtime-privileged behavior
|
||||
- Only `crates/manager-core/src/app_bootstrap.rs:39` references the slug at all, and only at fresh-install seed time. An attacker creating a new app and then deleting and recreating it cannot influence anything keyed on the slug — every authz check uses `app_id` (UUID), never the slug.
|
||||
|
||||
## Out of scope (cross-references)
|
||||
- Capability check on the inbound-email HMAC is in agent 3's lane (crypto).
|
||||
- Rate limits on the cap-check Argon2 surface — agent 7 (DoS).
|
||||
- The dashboard's localStorage echo of the bearer token leaking the principal — agent 6 (network/CSRF) and agent 1 (authn).
|
||||
- The principal-cache TTL window letting a freshly-revoked admin still execute one request — agent 1 (authn) F-P-009 follow-up.
|
||||
112
security_audit/03_crypto_secrets.md
Normal file
112
security_audit/03_crypto_secrets.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Cryptography & Secret Handling
|
||||
|
||||
Audit date: 2026-06-11
|
||||
Scope: `PICLOUD_SECRET_KEY` master-key sourcing, dev-mode bypass, AES-256-GCM envelope (`shared/src/crypto.rs`), per-app `secrets::*` store, encrypted email-trigger HMAC secret, `app_secrets` realtime signing key, API key + session token format and storage, HMAC verification paths, RNG selection, on-disk CLI credentials.
|
||||
|
||||
Methodology: read `crates/shared/src/{crypto,secrets,subscriber_token}.rs`, `crates/manager-core/src/{secrets_service,secrets_repo,app_secrets_repo,secrets_api,email_inbound_api,auth,auth_middleware,auth_api,auth_bootstrap,api_key_repo,admin_session_repo,users_service}.rs`, `crates/executor-core/src/sdk/{secrets.rs,stdlib/random.rs}`, `crates/picloud/src/main.rs`, `crates/picloud-cli/src/config.rs`, `crates/manager-core/migrations/{0022_app_secrets.sql,0023_secrets.sql}`. Cross-checked `AUDIT.md` to avoid duplicating fixed findings (`F-S-009` PICLOUD_DEV_INSECURE_KEY ack landed; verified the gate in `crypto.rs:207-212`).
|
||||
|
||||
## High
|
||||
|
||||
### `secrets` ciphertext is not bound to `(app_id, name)` — cross-tenant ciphertext swap by anyone with DB-write access
|
||||
- **Where**: `crates/shared/src/crypto.rs:71-85` (encrypt API has no AAD parameter), `crates/manager-core/src/secrets_service.rs:82-97` (`seal` calls `crypto::encrypt(&plaintext, master_key.as_bytes())` — no AAD), `crates/manager-core/src/secrets_repo.rs:145-167` (set), `crates/manager-core/migrations/0023_secrets.sql:14-22` (no AAD/MAC column).
|
||||
- **What**: AES-256-GCM accepts associated data for free, but PiCloud's envelope wraps only the plaintext. The `secrets` table stores `(app_id, name, encrypted_value, nonce)` and nothing in the ciphertext authenticates the tuple. Every secret in the database is encrypted under the *same* process-wide master key. Therefore: anyone who can issue a `UPDATE secrets SET encrypted_value=$1, nonce=$2 WHERE app_id=$3 AND name=$4` (or a row-level `INSERT ... ON CONFLICT DO UPDATE`) with payload lifted from another row can substitute *app B's* `stripe_key` ciphertext into *app A's* `stripe_key` row — and the very next `secrets::get("stripe_key")` in an app A script returns app B's plaintext sk_live token. The same applies row-to-row within an app (rename `db_password` → `db_admin_password` and decryption still succeeds). The blueprint's claim that the secrets store survives "row-level corruption" should also cover "row-level malicious overwrite," and it does not.
|
||||
- **Who can do this**: anyone with Postgres write access — a leaked `picloud` DB user; a backup attacker who restores a doctored dump; a sidecar that reads `DATABASE_URL` from env and reuses it; a future SQL-injection bug; a logical-replica that becomes writable on failover.
|
||||
- **Impact**: cross-tenant secret disclosure (High) — same severity as the corresponding code-path bug would carry, since the blueprint promises strict app isolation and the script-side `cx.app_id` discipline is the *only* claimed control. AAD would make the attack a guaranteed `AeadError` instead of a successful decrypt.
|
||||
- **Recommendation**: add `encrypt_with_aad` / `decrypt_with_aad` thin wrappers in `crates/shared/src/crypto.rs` using `Aes256Gcm::encrypt`'s existing `Payload { msg, aad }` form, and bind `aad = format!("secret:{app_id}:{name}").into_bytes()` from `seal`/`open` callsites. Same for `app_secrets` (`bind aad = "realtime_signing_key:{app_id}"`) and the email-trigger inbound secret (`bind aad = "email_trigger:{trigger_id}"`). Existing rows must migrate: either run a v1.1.10+ startup sweep that decrypts (no AAD), re-seals (with AAD), updates the row, or version-tag rows with a `key_version: u8` byte prepended so old + new can coexist during rollout. The blueprint's §12.x next-deliverable list already has a v1.2 "key versioning + re-encryption pass" — fold AAD migration into the same pass.
|
||||
|
||||
### Master-key sourcing accepts both standard and URL-safe base64, but the dev-key fallback is a SHA-256 of a public string — making "dev mode" trivially decryptable post-hoc
|
||||
- **Where**: `crates/shared/src/crypto.rs:178-187` (`from_base64` uses `STANDARD` engine only), `crates/shared/src/crypto.rs:246-251` (`dev_key` = `Sha256::digest(b"picloud-dev-master-key-v1.1.7")`).
|
||||
- **What**: The dev key is `SHA-256("picloud-dev-master-key-v1.1.7")`. Anyone reading the source can produce the exact 32-byte key. That is *correct* for a dev hatch, but if any operator misreads the comment ("Stable across restarts so dev secrets survive a reboot, but obviously not a real secret — the input is public") and runs a dev-mode instance against a real Stripe key, the attacker recovers every secret in any leaked DB dump from any dev install instantly. The startup gate (`MasterKeyError::DevModeUnacknowledged`) helps, but the warning is one `tracing::warn!` line — easy to miss in `RUST_LOG=info` operator setups, and not surfaced anywhere the dashboard would show.
|
||||
- **Impact**: Dev-mode dump exfil → full secret recovery (a Critical *if* it ever ships to prod; gating it as a High because the `DEV_INSECURE_KEY` ack is the first real defense and post-`F-S-009` it must be explicitly set).
|
||||
- **Recommendation**: (1) raise the warning to a sustained `tracing::error!`-level beacon re-emitted every ~5 minutes while dev-mode is active, so it lands in any observability backend. (2) Bake a startup health-check that fails liveness *if* `dev_mode && active_secret_count > 0 && process_uptime > 1h` — i.e., dev keys aren't allowed to accumulate prod-shaped data over time. (3) Surface "running with dev master key" as a banner in the dashboard `/version` payload so an operator visiting the UI sees it.
|
||||
|
||||
## Medium
|
||||
|
||||
### `MasterKey` and decrypted secret plaintext never zeroize on drop
|
||||
- **Where**: `crates/shared/src/crypto.rs:117-119` (`MasterKey { key: [u8; KEY_LEN] }` — no `Zeroize`/`ZeroizeOnDrop`), `crates/shared/src/crypto.rs:95-108` (`decrypt` returns `Vec<u8>`), `crates/manager-core/src/secrets_service.rs:104-115` (`open` returns `serde_json::Value`), `crates/manager-core/src/email_inbound_api.rs:197-214` (`decrypt_secret` returns `String`).
|
||||
- **What**: The 32-byte master key is held in `Arc<MasterKey>` shared across every service; copies of decrypted plaintext (stripe keys, OAuth secrets, HMAC secrets) live in heap `Vec<u8>`/`String`/`serde_json::Value` and rely on glibc allocator-zero to scrub them. A crash dump, a `core` file, an `/proc/$pid/mem` read, or a Spectre-style speculation leak from a coresident workload all see plaintext. The `secrecy` and `zeroize` crates exist precisely for this.
|
||||
- **Impact**: post-mortem secret recovery from memory artifacts; widens the blast radius of any RCE that lands enough to dump memory.
|
||||
- **Recommendation**: derive `Zeroize` on `MasterKey` (newtype `[u8; KEY_LEN]` already trivial); switch `crypto::decrypt`'s return to a `Zeroizing<Vec<u8>>` and propagate. For the email-trigger HMAC secret, wrap in `secrecy::SecretString` between decrypt and HMAC use. Whatever the SDK returns to the Rhai engine is necessarily long-lived (the Rhai Dynamic owns it), so the zeroize boundary should at least cover the intermediate `Vec<u8>` and the master key itself.
|
||||
|
||||
### Email-trigger inbound HMAC secret re-uses `crypto::encrypt` with no AAD (same bug class as above) and decrypts to an attacker-controlled string that becomes the verifier key
|
||||
- **Where**: `crates/manager-core/src/triggers_api.rs:585-610` (admin seals incoming `input.inbound_secret`), `crates/manager-core/src/email_inbound_api.rs:148-156, 197-214` (decrypts then uses `secret.as_bytes()` as HMAC key).
|
||||
- **What**: Same "no AAD binding to `trigger_id`" pattern as the per-app `secrets` finding above, except the consequence is sharper: the decrypted bytes become the *HMAC verifier key* on a public, unauthenticated webhook URL. If an attacker with DB-write access swaps trigger A's `inbound_secret_encrypted` for trigger B's, every signed payload addressed to trigger A is verified against trigger B's secret — and the attacker has just relocated their inbound webhook to a different script with different effects. The replay-dedup nonce (`F-S-010` follow-up at `email_inbound_api.rs:64-89`) does not help; the signature itself passes.
|
||||
- **Impact**: stronger variant of the secrets-table ciphertext-swap — it converts row-write access into ability to misdirect signed webhooks to scripts the attacker chose.
|
||||
- **Recommendation**: bind `aad = format!("email_trigger:{trigger_id}").into_bytes()` at the `seal`/`open` callsites in `triggers_api.rs:589-610` and `email_inbound_api.rs:148-156`. Migration story is the same as the `secrets` table — single startup sweep can re-encrypt with AAD.
|
||||
|
||||
### `app_secrets` realtime signing key is GCM-encrypted with no AAD — swap one app's key in, sign tokens that the SSE verifier accepts for the wrong app
|
||||
- **Where**: `crates/manager-core/src/app_secrets_repo.rs:99-101` (encrypt with no AAD), `crates/manager-core/src/app_secrets_repo.rs:78-91` (decrypt with no AAD), `crates/manager-core/migrations/0022_app_secrets.sql`.
|
||||
- **What**: Each app owns one HMAC signing key for realtime subscriber tokens (`crates/shared/src/subscriber_token.rs`). Both halves of the envelope (`realtime_signing_key_encrypted`, `realtime_signing_key_nonce`) live in `app_secrets`; nothing binds the row to `app_id`. A DB-write attacker who copies app B's `realtime_signing_key_*` columns into app A's row lets app A's SSE verifier accept tokens signed with app B's key — which by extension lets the attacker mint a token "for app A" using app B's key (which they own or can extract). Not a confidentiality break in the same way as the secrets-table case (the signing key is generated server-side, never visible to the attacker), but a clear authenticity break: the attacker can replay/forward tokens across apps.
|
||||
- **Impact**: cross-app realtime channel access if the attacker also controls (or has read access to) the source app's signing-key plaintext. Lower than the secrets finding because the attacker needs both DB-write AND knowledge of one app's plaintext signing key — but the attack lives in the same `crypto::encrypt`-with-no-AAD pattern.
|
||||
- **Recommendation**: bind `aad = format!("realtime_signing_key:{app_id}").into_bytes()`. Same migration sweep.
|
||||
|
||||
### `auth_middleware::resolve_principal` `principal_cache` keyed only on the SHA-256(token) — a stolen cache entry pre-image-strips the token's identity
|
||||
- **Where**: `crates/manager-core/src/auth_middleware.rs:202-216`.
|
||||
- **What**: The post-`F-P-009` cache is keyed on `hash_token(token)` (SHA-256 hex), but the cached `Principal` itself carries `user_id`, `instance_role`, `scopes`, `app_binding`. A read from the cache returns those directly without re-validating the session row, until cache TTL expires. If a session is *server-side revoked* (admin deactivates the user, password reset wipes sessions, key is revoked) but the cache still holds the entry, the next request that lands in the same process within the TTL window authenticates as the revoked principal. Tied with the F-S-001 dashboard-self-password-change gap from the prior audit (sessions not invalidated on rotation), the cache window becomes the "you successfully revoked nothing for 300 seconds" trap. Confirmed by reading `verify_session`+`verify_api_key`: neither rechecks the DB after a cache hit.
|
||||
- **Impact**: revocation lag bounded by cache TTL. Severity depends on TTL — the audit context says 60-300 s in the recommendation. Acceptable for normal session expiry but harmful for active-revocation cases.
|
||||
- **Recommendation**: (1) keep the cache; (2) on every "credentials changed" event (`logout`, `delete_session`, `expire_all_for_user`, `set_active(false)`, password change) emit an in-process "evict by `user_id`" hint that walks the cache and removes any entry with `Principal.user_id == revoked`. (3) Document the upper-bound revocation lag in `auth_middleware.rs`'s module doc so the next reader can reason about it.
|
||||
|
||||
### Decrypted secret materializes as a `String` in `email_inbound_api::decrypt_secret`; nothing zeroizes it before the HMAC instantiation borrows it
|
||||
- **Where**: `crates/manager-core/src/email_inbound_api.rs:197-214`.
|
||||
- **What**: Compounds the broader zeroize finding above. Worth a separate line because the HMAC secret is the strongest known authenticity primitive in the system; its memory residue is the most attractive heap target.
|
||||
- **Recommendation**: subsumed by the broader Medium above; flag here for callsite-specific Audit traceability.
|
||||
|
||||
## Low
|
||||
|
||||
### `crypto::CryptoError::Decrypt` and `CryptoError::InvalidNonce` are distinguishable, leaking a 1-bit "row corruption vs. tag mismatch" signal
|
||||
- **Where**: `crates/shared/src/crypto.rs:50-62, 95-108`.
|
||||
- **What**: A `CryptoError::InvalidNonce(n)` response tells the caller "the nonce column in this row has length n, not 12" before the AEAD even runs. The two error variants are folded together at the secrets-service layer (`secrets_service.rs:113` maps both to `SecretsError::Corrupted`), so the leak is internal-only today — but the `pub` enum on the `shared` crate guarantees future callers may keep them distinct. AEAD design lore is "fail with a single opaque error"; the documentation even references this. The check is also redundant: `Nonce::from_slice` will panic on the wrong length, and the existing service layer already collapses both. Inline the length check into the AEAD path and drop `InvalidNonce`.
|
||||
- **Recommendation**: collapse into a single `CryptoError::Decrypt`. The data-shape check (length != 12) can return the same variant without revealing which precondition failed — no realistic caller benefits from the distinction.
|
||||
|
||||
### `seal` and `open` accept plaintext lengths up to `usize::MAX`; only `SecretsService::set` enforces the 64 KB cap before sealing
|
||||
- **Where**: `crates/manager-core/src/secrets_service.rs:82-97`.
|
||||
- **What**: `seal` checks `plaintext.len() > max_value_bytes` *after* serializing — a malicious admin API path that wires a different `max_value_bytes` (or skips it) could end up feeding a 100 MB JSON-encoded value into `Aes256Gcm::encrypt` and allocate the ciphertext. The current `secrets_api` callsite at `secrets_api.rs:122` passes the configured cap, but the function is `pub`; future re-users may forget. Defense in depth: cap `seal` at a hard ceiling (e.g. 16 MiB) regardless of the configured limit.
|
||||
- **Recommendation**: add `const HARD_PLAINTEXT_CEILING_BYTES: usize = 16 * 1024 * 1024;` and return `SecretsError::TooLarge` when `plaintext.len() > min(max_value_bytes, HARD_PLAINTEXT_CEILING_BYTES)`. Cheap belt for any future caller.
|
||||
|
||||
### Email-inbound nonce dedup is process-local; clustered deployment can be replay-bypassed
|
||||
- **Where**: `crates/manager-core/src/email_inbound_api.rs:63-89`.
|
||||
- **What**: The comment in the module already calls this out (`Per-process is fine for single-node MVP and dev. … cluster mode (v1.3+) will need a shared store`). Flagging as **Info-with-a-pin** because moving to cluster mode without fixing this regresses to "5-minute replay window" — that is, the F-S-010 fix becomes silently no-op for inbound traffic that round-robins across nodes.
|
||||
- **Recommendation**: ship a `Trait NonceDedup` with a Postgres impl backed by a 5-min-TTL table (or Redis when that lands in v1.3+) before any cluster-mode flag flips on.
|
||||
|
||||
### `auth.rs::hex` reimplements lowercase hex by hand
|
||||
- **Where**: `crates/manager-core/src/auth.rs:97-105`.
|
||||
- **What**: Custom hex routine; correct but unmemorable. `hex::encode` is already a transitive dep (used in `email_inbound_api.rs`). Replacing closes one less footgun-shaped surface (`HEX` array bounds-by-construction is fine, but it's not what should land in `cargo audit` output).
|
||||
- **Recommendation**: replace with `hex::encode(&digest)`.
|
||||
|
||||
## Info
|
||||
|
||||
### Nonce generator is `rand::thread_rng()` (OS-seeded CSPRNG via `ChaCha12`) — fine for 2^32 encryptions per key
|
||||
- **Where**: `crates/shared/src/crypto.rs:73-83`, `crates/manager-core/src/app_secrets_repo.rs:99-101`.
|
||||
- **What**: `thread_rng()` is documented as a CSPRNG seeded from `OsRng`. For AES-GCM with a 96-bit random nonce, the safe encryption budget is ~2^32 messages per key before nonce-collision probability becomes non-negligible — well above any realistic per-app secret-write rate. No concern at the current scale; revisit if the same master key is ever used for high-volume per-row encryption (logs, pubsub bodies, etc.).
|
||||
- **Recommendation**: none for now; note in the master-key rotation design doc (v1.2+) that the per-key encryption budget is sub-2^32 messages by policy.
|
||||
|
||||
### `OsRng` is used for every other security-critical RNG (session tokens, API keys, `random::*` SDK, app signing key) — no `SmallRng`/`thread_rng` misuse for tokens
|
||||
- **Where**: `crates/manager-core/src/auth.rs:82-86, 143-147` (`OsRng`), `crates/executor-core/src/sdk/stdlib/random.rs:7-9, 51` (`OsRng`).
|
||||
- **What**: Verified across the workspace: every security-sensitive RNG path resolves to `OsRng` (or `Uuid::new_v4`, which is also `getrandom`-backed). The lone `thread_rng()` callers are GCM nonces and the realtime signing-key generator — both crypto-strength wrappers around the OS RNG. Acceptable.
|
||||
|
||||
### Constant-time HMAC verification is correct everywhere it matters
|
||||
- **Where**: `crates/shared/src/subscriber_token.rs:97-100` (`mac.verify_slice`), `crates/manager-core/src/email_inbound_api.rs:249-255` (`mac.verify_slice`).
|
||||
- **What**: Both signed-token verifiers use the `hmac` crate's constant-time `verify_slice`. Session token lookup uses SHA-256(token) hashed before any DB comparison — and the DB does the equality on the hash, so timing-side-channel risk on the wire token is bounded by the cost of SHA-256 (negligible vs. the network jitter floor). Argon2 verify is also constant-time by `argon2` crate construction. No `==`/`eq`/`.starts_with` on a remotely-supplied secret found anywhere in the workspace.
|
||||
|
||||
### CLI on-disk credentials are mode-0600 enforced, with re-set on each write
|
||||
- **Where**: `crates/picloud-cli/src/config.rs:91-110`.
|
||||
- **What**: `write_private` opens with `mode(0o600)` and re-applies permissions after write — the belt-and-suspenders comment is accurate. Tested by `posix_mode_is_0600`. No accidental loosening on overwrite.
|
||||
|
||||
### Bootstrap secrets are not logged
|
||||
- **Where**: `crates/manager-core/src/auth_bootstrap.rs:91-130` and `crates/picloud/src/main.rs:42-53`.
|
||||
- **What**: `bootstrap_first_admin_with` logs only `username` on success; both `password` and `password_hash` env-var values are never echoed. `tracing::warn!` paths use the env-var *names* but never values. No `PICLOUD_*` value leaks to logs.
|
||||
|
||||
### Key rotation is explicitly out of scope and documented
|
||||
- **Where**: `crates/shared/src/crypto.rs:19-22`.
|
||||
- **What**: Module-level doc states "Key rotation is out of scope for v1.1.7. Changing PICLOUD_SECRET_KEY between deploys orphans every existing ciphertext (it can no longer be decrypted). v1.2+ adds key-version columns + a re-encryption pass." Aligned with the AAD-migration recommendation above — both should land together in v1.2.
|
||||
|
||||
### TLS termination: Caddy fronts everything; upstream is plaintext on the same loopback by design
|
||||
- **Where**: design contract from `CLAUDE.md`.
|
||||
- **What**: No cross-host plaintext upstream observed. Single-node MVP runs everything on loopback; v1.3+ cluster mode is out of scope for this audit. Note the v1.3+ split must mutual-TLS the manager↔orchestrator and orchestrator↔executor hops, or move the cluster onto a private network with a network-layer encryption guarantee.
|
||||
|
||||
## Counts by severity
|
||||
- Critical: 0
|
||||
- High: 2
|
||||
- Medium: 5
|
||||
- Low: 3
|
||||
- Info: 6
|
||||
103
security_audit/04_injection.md
Normal file
103
security_audit/04_injection.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Injection & Query Safety
|
||||
|
||||
Audit date: 2026-06-11
|
||||
Scope: SQL injection (sqlx dynamic queries, ORDER BY / LIMIT / OFFSET dynamic clauses, JSONB operators, LIKE patterns, migrations, advisory locks), command injection (`std::process::Command` / `tokio::process`), log injection (newline-in-tracing-fields), HTTP response-header injection, SMTP-header injection on outbound mail, route matcher path normalisation, regex DoS, JSON body limits, and the Rhai → JSONB serialisation path.
|
||||
|
||||
Methodology: enumerated every `sqlx::query`, `query_as`, `format!` near SQL keywords, `QueryBuilder`, `bind()`, `push_str` near SQL, `EXECUTE`, `search_path` across the workspace; traced three dynamic builders end-to-end (`docs_repo::build_find_query`, `repo::list_for_user`, `topic_repo::create`); read every `process::Command` site; read every `tracing::{info,warn,error}!` site in `manager-core` for newline-in-value risk; read `email_service::build_message` and `lettre`'s SMTP-injection posture; read `Response::builder().header(...)` sites in `manager-core` and `orchestrator-core`; read `crates/orchestrator-core/src/routing/{pattern,matcher}.rs` for path parsing; read `crates/executor-core/src/sdk/stdlib/regex.rs`; read `caddy/Caddyfile{,.prod}`. Cross-checked `AUDIT.md` (no overlap — Q-001 and friends sit at the architectural-arrow boundary, not the SQL injection surface).
|
||||
|
||||
## Headline: the SQL layer is parameterized cleanly end-to-end
|
||||
|
||||
The codebase has **75** dynamic `sqlx::query(…)`/`query_as(…)` callsites and **zero** uses of the compile-time-checked `sqlx::query!` macro. Despite the lack of macro coverage, **every** dynamic call I read uses positional `$1..$N` parameters and `.bind(…)`; no callsite interpolates a user-controlled value into the SQL text. The `format!` interpolations that survive into the SQL string only inject a hardcoded `&str` constant (`SCRIPT_SELECT_COLS` / `SELECT_COLS`) so the column list isn't duplicated across queries — see `repo.rs:211`, `topic_repo.rs:144`, etc. Two dynamic builders that take user input — `docs_repo::build_find_query` (the v1.1.2 docs find-DSL) and `kv_repo::list` keyset cursor — both run user-controlled `path` segments and comparison values through `QueryBuilder::push_bind`, not `push`. The structural risk of an SQL-injection bug landing in a future patch is real because the macro form isn't enforced, but no live SQL-injection finding emerged from this pass.
|
||||
|
||||
## High
|
||||
|
||||
_(None.)_
|
||||
|
||||
## Medium
|
||||
|
||||
### Response-header injection / handler panic via attacker-controlled `files.content_type`
|
||||
- **Where**: `crates/manager-core/src/files_api.rs:158-164` (build response), `crates/shared/src/files.rs:190-211` (`NewFile::validate` — accepts any `content_type` that is non-empty and ≤ `MAX_CONTENT_TYPE_BYTES`).
|
||||
- **What**: `files::create` is reachable from any Rhai script (incl. anonymous public HTTP routes). `NewFile::validate` enforces no upper bound on character set: empty/trim check + length check + nothing else. The stored `content_type` is then plumbed directly into `Response::builder().header(CONTENT_TYPE, meta.content_type)` on `GET /api/v1/admin/apps/:id/files/:col/:fid`. `axum::http::Response::builder().header(...)` defers the `HeaderValue` parse until `.body()`, and the file API uses `.expect("build files download response")` — so a stored `content_type` of `"text/plain\r\nX-Injected: yes"` makes every subsequent admin-side download for that row panic the handler. Axum catches the panic per-task (no process crash), but the dashboard's download page 500s and that single file is effectively poisoned for download until manually edited. A determined script can fill an app's files collection with poisoned content_types and break admin file listings. Additionally, if a future code path streams the file to a non-admin context that has weaker error handling, the CRLF would smuggle a forged header through.
|
||||
- **Recommendation**: extend `NewFile::validate` / `FileUpdate::validate` to reject any `content_type` that fails `HeaderValue::from_str` (or, cheaper, reject any byte in `\x00..=\x1f` and any non-ASCII). Mirror the same check on `name` (already sanitised at the response site in `files_api.rs:155` but only there — a future route that materialises `name` into a header without that scrub would inherit the bug). Belt: change `files_api.rs:164` from `.expect(...)` to a 500-with-JSON fallback so a single poisoned row can't keep panicking the request task.
|
||||
|
||||
### `tracing` fields embed script-controlled strings — log forging under text-formatter
|
||||
- **Where**: `crates/manager-core/src/dispatcher.rs:242` (`queue = %consumer.queue_name`), `crates/manager-core/src/files_repo.rs:469` (`path = %path.display()`), `crates/manager-core/src/files_sweep.rs:50, 119` (env value + sweep path), `crates/manager-core/src/users_service.rs:726, 815` (rate-limit warnings carry no user-controlled strings, but `dispatcher.rs:242` does — `queue_name` comes from a script's `queue::enqueue(name, …)` call).
|
||||
- **What**: With the default `tracing_subscriber` text formatter, log lines are emitted as `field=value` with **no escaping of newlines** in the value. A script that creates a queue named `"prod\n2026-06-11T00:00:00 INFO admin login succeeded user_id=00000000-0000-0000-0000-000000000000"` writes a forged login line into every operator's log stream the next time the dispatch loop logs an error against that queue. Even without text-formatter, downstream log aggregators that split on `\n` are confused. The JSON formatter escapes; the text formatter (which is what `picloud/src/lib.rs` likely installs in dev/MVP) does not. None of the other fields I sampled carry script-input values, so the surface is narrow — but a queue-name log line is the one that lands during steady-state operation.
|
||||
- **Recommendation**: (a) standardise on the JSON formatter for non-dev deployments, documenting that `tracing_subscriber::fmt::format::Json` escapes control chars; (b) add a small `log_safe(s: &str) -> Cow<'_, str>` helper that replaces `\n`/`\r`/control chars and apply at the script-input boundary (queue names, topic patterns, file collection names, KV collection names) at the `tracing` callsite; (c) sanitise queue/topic/collection identifiers at registration to a `[a-zA-Z0-9_.-]` allowlist — this also tightens the input domain for the parsers further downstream. Linked to F-S-001 (the broader "scripts can DoS the platform via unbounded inputs") in `AUDIT.md`.
|
||||
|
||||
### SMTP outbound: `subject` / `from` / `reply_to` pass to `lettre` without explicit CRLF scrub
|
||||
- **Where**: `crates/manager-core/src/email_service.rs:276-321` (`build_message`), `crates/manager-core/src/email_service.rs:330-353` (`parse_address`).
|
||||
- **What**: `subject` is `builder.subject(email.subject.clone())` — lettre's header writer rejects CR/LF in the resulting `HeaderValue` (returning `lettre::error::Error::EmailMissingAt`-family at `singlepart`/`multipart`), but the error surfaces as `EmailError::Transport(e.to_string())`, which is mapped to a string in the `OutboundEmail` flow. Functionally lettre saves us from the CRLF-into-SMTP-headers attack; structurally the safety lives in a third-party crate's behaviour, with no in-tree test pinning it. An upgrade that relaxes lettre's header sanitiser (or a switch to a different transport later) silently regresses to SMTP header injection. `parse_address` rejects bad shapes but accepts CRLF inside a quoted display-name. Defence-in-depth.
|
||||
- **Recommendation**: add a `reject_header_unsafe(s: &str) -> Result<(), EmailError>` that fails on any byte `< 0x20` (and `0x7f`) and call it on `subject`, `from`, `reply_to`, and every entry of `to`/`cc`/`bcc` before they reach lettre. Cheap, future-proof, and pins the boundary against transport-library churn.
|
||||
|
||||
### Rhai `regex::*` compiles arbitrary user patterns with no length cap and no compile-time-budget enforcement
|
||||
- **Where**: `crates/executor-core/src/sdk/stdlib/regex.rs:39-51`.
|
||||
- **What**: PiCloud uses the Rust `regex` crate (non-backtracking, linear-time RE2 family) — so the classic catastrophic-backtracking ReDoS doesn't apply. But the `regex` crate still defends against exponential-NFA-size attacks with a `size_limit` (default 10 MB). A script can pass a pattern that hits exactly that ceiling per-compile, and the compile itself is expensive. The thread-local LRU caches by string key (`pattern.to_string()`), so a script that loops over 129 distinct patterns evicts the cache and pays full compile every iteration. Compounded by the existing F-P-014 thread-local design, a single script can burn CPU compiling 50-MB-of-NFA-state patterns serially until the execution budget runs out. Today the executor's wall-clock + operations budget caps this, so the worst-case is "the script's own request burns its own budget" — but the per-app and per-process budgets aren't tightened against regex compile, and a public route that compiles a script-supplied user-input regex is one script-side bug away from the full-process DoS.
|
||||
- **Recommendation**: enforce a per-call hard limit on the pattern length (e.g., 4 KiB) before calling `Regex::new`; consider `regex::RegexBuilder::size_limit(usize)` capped lower (e.g., 256 KB) than the crate default; and document the pattern-cache semantics so script authors know the compile cost is not amortised across distinct strings. Cheap belt against the future "public route validates an attacker-supplied regex".
|
||||
|
||||
### `email_inbound_api` parses untrusted JSON body without an explicit size cap before deserialisation
|
||||
- **Where**: `crates/manager-core/src/email_inbound_api.rs:160`.
|
||||
- **What**: `serde_json::from_slice(&body).map_err(...)` — `body` arrives via axum's default body extractor and there's no `DefaultBodyLimit::max(...)` configured for the email-inbound route I could find. By contrast, the user-route handler in `orchestrator-core/src/api.rs:219` reads body bytes with an explicit 10 MiB cap (`to_bytes(...,10 * 1024 * 1024)`). The email-inbound webhook is public (HMAC-authenticated but discoverable), so an attacker can send a 1 GB JSON body and force a slow parse + large allocation per request, possibly bypassing the HMAC check (since the body is read before the HMAC verify in the handler — verify this end-to-end). Likely capped at the axum default of 2 MiB anyway, but pinning it explicitly is cheap and avoids the "future maintainer adds `.layer(DefaultBodyLimit::disable())`" regression.
|
||||
- **Recommendation**: apply `axum::extract::DefaultBodyLimit::max(256 * 1024)` to the email-inbound route, matching `PICLOUD_EMAIL_MAX_MESSAGE_BYTES`'s logical scale. While here, do the same on every other ingress route that takes a JSON body — they should all opt in to an explicit cap rather than rely on the default.
|
||||
|
||||
## Low
|
||||
|
||||
### `auth_api.rs:228` builds `Set-Cookie` via `format!` with an internally-generated token
|
||||
- **Where**: `crates/manager-core/src/auth_api.rs:228-232`.
|
||||
- **What**: The `Set-Cookie` value `format!("{SESSION_COOKIE}={raw_token}; HttpOnly{secure_attr}; …")` interpolates `raw_token` which is generated server-side (`generate_session_token`). No untrusted input today. Flagging because the `format!` shape is the canonical CRLF-smuggling footgun — if a future refactor lets a caller-supplied token reach this path (e.g., a "remember-me" import flow), the lack of `HeaderValue::from_str` validation at the boundary becomes a header-injection bug.
|
||||
- **Recommendation**: build via `HeaderValue::from_str(...)?` (the caller already does at `auth_api.rs:145`) and surface the parse error as 500 rather than silently emitting an empty cookie. The existing `.unwrap_or_else(|_| HeaderValue::from_static(""))` (line 148) hides a tamper case.
|
||||
|
||||
### `route_admin`'s reserved-path check is prefix-based; `..` and percent-encoded `/` in literal segments aren't normalised by the matcher
|
||||
- **Where**: `crates/orchestrator-core/src/routing/pattern.rs:110-117` (reserved check), `crates/orchestrator-core/src/routing/matcher.rs:279-302` (`match_param`, `request_path.trim_start_matches('/').split('/')`), `crates/orchestrator-core/src/api.rs:175` (`request.uri().path().to_string()`).
|
||||
- **What**: The matcher uses Axum's `uri().path()` which does NOT decode percent-encoded characters (e.g., `%2F`, `%2E`). So a user route bound to `/admin-foo` is safely never reachable by a request to `/api/v1/admin/.../?next=%2Fadmin-foo`. But: `parse_param` calls `split('/')` directly on the raw path, and a request to `/users/%2Fid` segments to `["users", "%2Fid"]`, capturing `id = "%2Fid"`. The capture is then handed to the script as-is. If a script naively re-emits the capture into another HTTP request or into a filesystem operation (files API), the encoded `/` decodes downstream and is treated as a path separator. Out-of-scope here (the route matcher itself is internally consistent) but adjacent to the path-traversal questions agent 6 is handling. Same for `..`: `match_param` will happily capture `..` as a param value; no normalisation, so a script doing `files::get(collection, id_from_capture)` is on its own. The collection validator in `files.rs:259` does check for `..` and `/`, but the file_id capture does not.
|
||||
- **Recommendation**: in the matcher, percent-decode each captured segment and reject (or normalise) any segment that decodes to `/`, `..`, or `\0`. This aligns the matcher's per-segment semantics with the file-collection validator and removes a class of "the SDK trusted what the matcher gave me" surprises.
|
||||
|
||||
### Email-trigger HMAC verify reads `x-picloud-timestamp` and parses without strict bounds
|
||||
- **Where**: `crates/picloud/tests/email_inbound.rs:161-212` shows the surface; the inbound handler reads timestamp via header and includes it in the HMAC payload.
|
||||
- **What**: Log forging via `x-picloud-timestamp` is theoretically possible if the value is logged unsanitised. Quick scan of `email_inbound_api.rs` did not surface a tracing line that emits the header value, so this is preventive — calling out that any future `tracing::warn!(timestamp = %ts, …)` line on a malformed signature would re-introduce the log-injection surface from the Medium above.
|
||||
- **Recommendation**: add explicit `parse::<u64>()` on the header value at the boundary, store the parsed integer in the local, and never emit the raw header string into a log line.
|
||||
|
||||
### `format!`-based dynamic SQL builders interpolate compile-time constants only, but the macro form (`sqlx::query!`) is not used anywhere — defence-in-depth gap
|
||||
- **Where**: `crates/manager-core/src/repo.rs:211, 225, 236, 245, 263, 279, 333, 423` etc. (the `&format!("SELECT {SCRIPT_SELECT_COLS} …")` pattern), `crates/manager-core/src/topic_repo.rs:144, 163, 173, 191` (same with `SELECT_COLS`).
|
||||
- **What**: Every interpolation expands a `const &str` — there is no live SQL-injection here. But because the codebase has chosen the `sqlx::query(...)` (string) form over the `sqlx::query!(...)` (macro, compile-time-checked) form everywhere, an injection bug becomes a `cargo clippy`-invisible regression. Today's discipline is "every `format!`-into-SQL only interpolates from a hardcoded constant" — that invariant lives in the heads of reviewers, not the type system. A drive-by refactor that replaces `SCRIPT_SELECT_COLS` with `&format!("{prefix}, id, …")` and lets `prefix` come from input is the canonical bug-shaped change.
|
||||
- **Recommendation**: write a project-rule lint (or a CI grep) that fails on `format!\(.*"(SELECT|INSERT|UPDATE|DELETE|WITH)` outside an allowlist of files. Alternative: migrate the hottest `sqlx::query(...)` sites to `sqlx::query!(...)` so the macro errors at compile time when the columns change shape — pays for itself the next time a migration adds a column.
|
||||
|
||||
## Info
|
||||
|
||||
### `docs_repo::build_find_query` is the most load-bearing dynamic SQL builder and is parameterized correctly
|
||||
- **Where**: `crates/manager-core/src/docs_repo.rs:308-420`.
|
||||
- **What**: Every value goes through `qb.push_bind(...)`; the only literal `qb.push("…")` strings are SQL keywords / operators selected from a closed enum (`ComparisonOp`, `SortDir`). Path segments (`FieldPath::segments`) are also bound (`push_bind(seg.as_str())`) into `jsonb_extract_path_text(data, $N1, $N2, …)`. The DSL parser (`docs_filter.rs:85-115`) rejects depth > 5, `..`, `$`-prefixed segments, and `.`-prefixed/-suffixed segments. The `$limit` clause goes through `qb.push_bind(i64::from(limit))` and is hard-capped at `DOCS_LIST_MAX_LIMIT`. ORDER BY direction comes from a closed enum. **This is the right shape.**
|
||||
|
||||
### `kv_repo::list` keyset cursor is parameter-bound; cursor decode is total
|
||||
- **Where**: `crates/manager-core/src/kv_repo.rs:188-200`, `218-223`.
|
||||
- **What**: `$3::text IS NULL OR key > $3` with `last_key` (Option<String>) bound. Cursor decoded as base64-URL-safe-no-pad → UTF-8 → bound text. Decode errors return `InvalidCursor` cleanly. No injection surface; cursor tampering at worst skips rows.
|
||||
|
||||
### `dead_letter_repo::resolve` validates `reason` against a fixed allowlist
|
||||
- **Where**: `crates/manager-core/src/dead_letter_repo.rs:104-105, 183-200`.
|
||||
- **What**: `ALLOWED_RESOLUTIONS = &["replayed", "ignored", "handled_by_script", "handler_failed"]`; checked before the SQL UPDATE. Even though the reason then goes through `.bind($2)` (so injection wasn't possible anyway), the allowlist gives a clean error early. Right pattern; consider replicating wherever a free-form "reason" or "status" field could appear in the future.
|
||||
|
||||
### `process::Command` usage is confined to test code
|
||||
- **Where**: `crates/picloud-cli/tests/common/server.rs:58`.
|
||||
- **What**: The only `std::process::Command` callsite anywhere in the workspace spawns the `picloud` binary with the test's own controlled env. No `sh -c`, no shell escape, no user input. The production CLI (`picloud-cli`) is pure HTTP — no shell-out for git/curl/etc. **Hardened by absence.**
|
||||
|
||||
### Caddy reverse-proxy config doesn't construct headers from input
|
||||
- **Where**: `caddy/Caddyfile`, `caddy/Caddyfile.prod`.
|
||||
- **What**: Static `handle` directives + static error bodies. No `header` directive interpolates a captured request value into a response header. Caddy's own request normalisation drops CR/LF from headers before reaching the upstream.
|
||||
|
||||
### No `LIKE` / `ILIKE` patterns in the repo layer; no dynamic ORDER BY / LIMIT / OFFSET from input
|
||||
- **Where**: workspace-wide `rg 'LIKE \|ILIKE'` → 0 hits in `crates/manager-core/src/`. `ORDER BY` is either a static string or a closed-enum (`SortDir`) selection in `docs_repo`. `LIMIT`/`OFFSET` are bound parameters with hard upper-caps. `execution_logs::list_for_script` (F-P-005 in `AUDIT.md`) uses OFFSET but with a bound integer — performance issue, not injection.
|
||||
|
||||
### Postgres advisory-lock keys are derived from a stable `DefaultHasher`, not user input directly
|
||||
- **Where**: `crates/manager-core/src/trigger_repo.rs:1346-1359`.
|
||||
- **What**: `pg_advisory_xact_lock($1)` with a `bind(lock_key)` where `lock_key` is `DefaultHasher.finish() as i64` of `(app_id, queue_name)`. Untrusted `queue_name` only feeds the hash, not the SQL text. Good.
|
||||
|
||||
### JSONB value serialisation: scripts → `serde_json::Value` → sqlx → `JSONB` — no string interpolation in the chain
|
||||
- **Where**: `crates/manager-core/src/kv_repo.rs:111-130`, `crates/manager-core/src/docs_repo.rs` (write paths), `crates/manager-core/src/log_sink.rs:24-54`.
|
||||
- **What**: Every JSONB write column binds a `serde_json::Value` directly (sqlx's `Json`/`JsonValue` ↔ `JSONB` codec is binary-safe). No path constructs a JSON string by hand and feeds it to `INSERT … VALUES ('{...}'::jsonb)`. The blueprint's promise that JSONB is the storage shape is upheld at the boundary.
|
||||
|
||||
### `serde_json::from_slice` on untrusted bodies is gated by axum body-size limits where it matters
|
||||
- **Where**: `crates/orchestrator-core/src/api.rs:219-228` (10 MiB cap, then parse). `crates/manager-core/src/email_inbound_api.rs:160` lacks an explicit cap — see Medium above.
|
||||
|
||||
### Migrations are static SQL; no `EXECUTE format(…)` or `search_path` manipulation
|
||||
- **Where**: `crates/manager-core/migrations/*.sql`.
|
||||
- **What**: All 41 migrations are static text. No `DO $$ … EXECUTE format('…') $$;` block. The schema-snapshot test (`tests/schema_snapshot.rs`) pins the final shape so a future dynamic-SQL migration would have to update the snapshot, surfacing the change in review.
|
||||
146
security_audit/05_sandbox_exec.md
Normal file
146
security_audit/05_sandbox_exec.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Security Audit — Rhai Sandbox & Script Execution
|
||||
|
||||
**Scope:** the boundary between untrusted user Rhai scripts and the picloud host process. Engine setup, sandbox limit enforcement, wall-clock + memory budgets, SDK attack surface, info disclosure.
|
||||
|
||||
**Reference HEAD:** `main` @ v1.1.9, files under `crates/executor-core/` + `crates/orchestrator-core/{client.rs,gate.rs}` + `crates/manager-core/dispatcher.rs`.
|
||||
|
||||
**Header counts:** Critical 0 / High 4 / Medium 6 / Low 5 / Info 4.
|
||||
|
||||
The sandbox is well-thought-out at the engine-config level (all six Rhai per-call ceilings set + admin clamping at write time + per-app module resolver with cycle and depth detection + no unsafe blocks). The defensive boundary is breached not by code-injection but by **resource amplification**: a single anonymous script call can wedge a blocking thread for the full 5-minute hard cap, propagate uncensored internal error strings to the HTTP response, and consume unbounded SDK service calls per execution. No sandbox-escape was found.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
_None._
|
||||
|
||||
The four `set_max_*` calls plus `module_resolvers::DummyModuleResolver` (before the per-call `PicloudModuleResolver` replaces it) plus `disable_symbol("print")` plus the absence of `register_custom_syntax`, file/process/network primitives (file/process are absent; network is gated by `SSRF` in `manager-core/src/ssrf.rs`) keep the door shut to direct native code execution. No `unsafe` blocks exist anywhere in `executor-core` or `orchestrator-core`.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### F-SE-H-01 — `tokio::time::timeout` over `JoinHandle` does NOT cancel a `spawn_blocking` task; runaway script keeps its OS thread until self-completion
|
||||
|
||||
- **Location:** [crates/orchestrator-core/src/client.rs:207-216](../crates/orchestrator-core/src/client.rs#L207-L216), [crates/orchestrator-core/src/client.rs:240-249](../crates/orchestrator-core/src/client.rs#L240-L249)
|
||||
- **Summary:** The code path is `let join = spawn_blocking(...); tokio::time::timeout(timeout, join).await`. When the timeout fires the future is dropped — but **dropping a `JoinHandle` does not cancel `spawn_blocking`** (Tokio cannot interrupt a synchronous OS thread). The script keeps running until either the operation-budget exhausts (currently 1M ops, ~seconds on tight loops, but a script that calls into the SDK `block_on` will only count one op per service call and may sleep arbitrarily) or it returns voluntarily. The author acknowledges this in the comment at lines 184-192 but the consequence is understated: the `_permit` releases, so the gate admits a new request — **the blocking thread is NOT released**. Under load, every timeout-exceeded script subtracts one thread permanently from the Tokio blocking pool (default 512), but on a low-memory single-Pi deployment the OS will run out of stack first.
|
||||
- **Concrete amplification:** any anonymous script can do `loop { kv::get("x", "y"); }` (each iteration: 1 Rhai op + ~1 ms block_on → after 1M ops, ~16 min of wall-clock have passed; `HARD_TIMEOUT_CAP = 300s` will fire long before the op budget) → the orchestrator returns `Timeout` after 300s, but the thread keeps running until the budget exhausts (potentially another 10+ minutes). N concurrent attackers = N stuck threads.
|
||||
- **Severity:** High (single anonymous request can permanently occupy a worker thread; chained → denial-of-service).
|
||||
- **Fix:**
|
||||
1. Move the wall-clock interrupt to inside the Rhai engine via `Engine::on_progress` (the closure is called for every op; check `Instant::now() - started > soft_deadline` and return `Some(Dynamic::UNIT)` to halt eval). Use the existing op counter as the carrier.
|
||||
2. Wrap the spawn_blocking in a worker that also receives a `tokio::sync::watch` cancellation channel; the on_progress closure polls it and aborts.
|
||||
3. As a defense-in-depth, plumb an OS rlimit/cgroup cap on per-thread CPU time (Linux `RLIMIT_CPU` per task) so a wedged thread eventually dies from `SIGXCPU`.
|
||||
|
||||
### F-SE-H-02 — SDK service calls (KV/docs/pubsub/queue/secrets/files/users/email/http/invoke) are not rate-limited per execution; one invocation can issue unbounded I/O
|
||||
|
||||
- **Location:** [crates/executor-core/src/sdk/](../crates/executor-core/src/sdk/) — every `register*` function. No counter, no cap. Each SDK call costs 1 Rhai op (the function-call op), so `Limits::max_operations = 1_000_000` is the only ceiling, and `block_on` time isn't counted at all.
|
||||
- **Summary:** A 5-line script `for n in 0..1_000_000 { kv.get("x"); }` makes a million round-trips against Postgres. AUDIT.md F-S-001 already catches the per-call size leak; this finding catches the per-execution **count** leak. The sister storage services (docs, files, secrets, queue, pubsub, users) all share the shape — none of them maintain a per-execution counter visible to the caller.
|
||||
- **Severity:** High (one anonymous public-HTTP route can saturate the DB pool and amplify into N file-system writes for `files::create`, N SMTP relays for `users::send_verification_email`, N outbox rows for `pubsub::publish_durable`).
|
||||
- **Fix:** Add a `Counters` struct in `SdkCallCx` (`AtomicU32` per service category + per-call cap, e.g., 1024 KV ops, 64 pubsub publishes, 16 emails, 8 outbound HTTP). Increment+check at the top of each `register_*` closure; throw `EvalAltResult::ErrorRuntime("kv: per-execution call cap (1024) exceeded")` on overflow. Same place a per-execution `Counters` lives, so `invoke()` callees get their own counters (defense for re-entrant abuse) but the **same global per-root-execution counter** is bumped to defeat chained-invoke amplification.
|
||||
|
||||
### F-SE-H-03 — `print` is disabled but `debug` is not; Rhai's `debug()` writes to stderr by default and the comment claims both are disabled
|
||||
|
||||
- **Location:** [crates/executor-core/src/engine.rs:317-320](../crates/executor-core/src/engine.rs#L317-L320)
|
||||
- **Summary:** The comment says "Rhai's built-in print and debug map to stdout/stderr by default; we never want scripts dumping there directly." But only `engine.disable_symbol("print")` is called. `debug(value)` (the Rhai built-in) still routes through whatever `on_debug` callback is installed; if none is installed, it goes to `eprintln!`. On a serverless host where stderr is captured to a journald/Docker log stream, an attacker can write arbitrary bytes — control characters, color escapes, ANSI cursor manipulation, long lines — into the host's stderr stream. This is an **info-disclosure / log-injection** vector that the codebase's author already noticed but didn't completely fix.
|
||||
- **Severity:** High (any anonymous script can write arbitrary lines into the operator's terminal/logs; trivially escapes the operator's grep/filter).
|
||||
- **Fix:** `engine.disable_symbol("debug");` on the line below, OR install `engine.on_debug(|_, _, _| {})` (no-op handler). Belt-and-suspenders: also `disable_symbol("eval")` to be explicit about it being unavailable.
|
||||
|
||||
### F-SE-H-04 — `ExecError::Runtime` propagates verbatim Rhai/SDK error text to the public HTTP response body (info disclosure)
|
||||
|
||||
- **Location:** [crates/orchestrator-core/src/api.rs:753](../crates/orchestrator-core/src/api.rs#L753), error construction in [crates/executor-core/src/engine.rs:683-689](../crates/executor-core/src/engine.rs#L683-L689) (`map_eval_error` falls through to `ExecError::Runtime(other.to_string())` for every non-`TooManyOperations` non-`ErrorParsing` variant).
|
||||
- **Summary:** Anything that throws inside the sandbox — `kv: connection refused`, `http: blocked by SSRF policy: link-local`, `secrets: cannot decrypt with master key (key rotation in progress)`, `invoke: depth limit exceeded`, and any panic in a registered fn — surfaces as a 502 Bad Gateway with the full message in the JSON body, sent to whoever called the public route. A red-team attacker uses this as a reconnaissance oracle:
|
||||
- "blocked by SSRF policy: link-local" → confirms the host runs in a cloud environment with metadata service.
|
||||
- "kv: pool acquire timeout" → confirms DB pool exhaustion (lets the attacker time their attacks).
|
||||
- "secrets: ... <internal path>" → file paths.
|
||||
- "execution task panicked: <Rust backtrace fragment>" if `RUST_BACKTRACE=1` (line 211-213 — `format!("execution task panicked: {join_err}")` flows into `Runtime`, which renders directly in the JSON body).
|
||||
- The module resolver explicitly redacts this case for backend errors (`module_resolver.rs:344-355` — "module backend unavailable; check server logs") — but the rest of the SDK does not.
|
||||
- **Severity:** High (low-effort reconnaissance plus log-injection-by-script via `throw "<crafted message>"` — the thrown string is the runtime error string, which becomes the response body, reflecting attacker-controlled content into operator-facing JSON without a `Content-Type` check).
|
||||
- **Fix:** In `ApiError::into_response` for unauthenticated requests, replace `ExecError::Runtime(s)` body with a stable generic message and log the original with `tracing::error!` at INFO/ERROR. Add a `principal: Option<Principal>` field to `ApiError` (carried from the request) and gate verbose error text on authentication. Mirror the module-resolver redaction.
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### F-SE-M-01 — No script-source byte cap; parser cost is unbounded at script-upload + per-`Engine::execute` (when AST cache misses)
|
||||
|
||||
- **Location:** [crates/executor-core/src/engine.rs:182-188](../crates/executor-core/src/engine.rs#L182-L188), [crates/executor-core/src/engine.rs:195-205](../crates/executor-core/src/engine.rs#L195-L205)
|
||||
- **Summary:** `Engine::compile` and `Engine::execute(source, ...)` accept arbitrary `&str`. Neither path checks source length. The admin script-upload endpoint may impose its own cap (not audited here — agent 4's scope), but `LocalExecutorClient::execute` (legacy non-identity path; line 207) does not. A 100 MB Rhai source parses for a long time and allocates a correspondingly huge AST. The `max_expr_depth` only catches deeply *nested* expressions, not wide ones.
|
||||
- **Severity:** Medium (this needs an attacker to have script-write authz, so it's lower-impact, but it's a one-line script edit to crash the parser).
|
||||
- **Fix:** Add `const MAX_SOURCE_BYTES: usize = 256 * 1024;` validated in `Engine::compile` / `Engine::validate` / `Engine::execute`; reject above with `ExecError::Parse("script source exceeds 256 KiB")`. Match the per-key value cap in `PICLOUD_KV_MAX_VALUE_BYTES`.
|
||||
|
||||
### F-SE-M-02 — `Limits` does not include a `max_modules` cap; deep but valid `import` graphs allocate unbounded module instances
|
||||
|
||||
- **Location:** [crates/executor-core/src/sandbox.rs:46-58](../crates/executor-core/src/sandbox.rs#L46-L58)
|
||||
- **Summary:** `module_import_depth_max = 8` caps the import chain depth, but a single module can `import` 1000 distinct other modules. Each one compiles + evaluates → 1000 `Shared<Module>` allocations + their AST + their `Scope`. The LRU module cache (`DEFAULT_MODULE_CACHE_SIZE = 512`) caps long-term memory but not the per-execution allocation pressure during a cold-cache scenario.
|
||||
- **Severity:** Medium (requires the attacker to be a script author with write authz; matches F-SE-M-01's threat model).
|
||||
- **Fix:** Add `max_modules_per_execution: u32` (default 64) to `Limits`; track in the resolver state alongside `depth`, throw on overflow.
|
||||
|
||||
### F-SE-M-03 — `invoke()` re-entry inherits caller's permits (no new gate acquire) and shares the caller's blocking thread; a depth-8 chain holds one slot for 8x as long
|
||||
|
||||
- **Location:** [crates/executor-core/src/sdk/invoke.rs:119-217](../crates/executor-core/src/sdk/invoke.rs#L119-L217)
|
||||
- **Summary:** `invoke_blocking` calls `self_engine.execute_ast(&ast, req)` directly from inside the caller's `spawn_blocking` thread. Comment at line 120-122 explains this as a feature ("the outer execution is already gate-admitted, no new spawn_blocking — we'd deadlock if we nested inside the blocking pool"). Correct as written, but: each level of `invoke()` runs synchronously inside the same thread, accumulating the full call tree's CPU time against one slot. `trigger_depth_max = 8`, so a worst-case chain is 8 scripts deep, each doing 5 minutes of work → 40 minutes of one blocking thread held under one gate permit.
|
||||
- **Severity:** Medium (the global thread budget is 32 by default; 8 attackers each calling a depth-8 chain saturates the gate for 40 min).
|
||||
- **Fix:** Per-execution wall-clock budget that is *inclusive* of `invoke()` callees: pass `Instant + deadline` into `SdkCallCx`, check in invoke before each callee's `execute_ast`, throw `DeadlineExceeded` early. Combined with F-SE-H-01's `on_progress` hook this becomes airtight.
|
||||
|
||||
### F-SE-M-04 — `json::parse` accepts arbitrary nesting; serde_json default recursion is unbounded → stack overflow
|
||||
|
||||
- **Location:** [crates/executor-core/src/sdk/stdlib/json.rs:17-23](../crates/executor-core/src/sdk/stdlib/json.rs#L17-L23)
|
||||
- **Summary:** `serde_json::from_str` recurses on nested objects/arrays. A `{"a":{"a":{"a":...}}}` with 10K levels stack-overflows the OS thread. This is `spawn_blocking`, so the stack size is whatever Tokio sets (default 2 MB) — 10-30k levels of object nesting hits it. Resulting abort is **process-fatal** for the whole picloud binary on a single-node deployment.
|
||||
- **Severity:** Medium (process kill = service outage; needs a script with `json::parse` and untrusted input — which is the default for any public HTTP route reading `ctx.request.body`).
|
||||
- **Fix:** Switch to `serde_json::Deserializer::from_str(s).into_iter::<Value>()` with a recursion-limiting `read::SliceRead`, OR re-use Rhai's `max_expr_depth` semantics by writing a small bounded-depth parser. The cheapest fix: catch with `std::panic::catch_unwind` (won't catch stack overflow, but catches most parser panics) and document the limitation.
|
||||
|
||||
### F-SE-M-05 — `regex::*` accepts user-supplied patterns with no complexity / size cap; ReDoS DoS surface
|
||||
|
||||
- **Location:** [crates/executor-core/src/sdk/stdlib/regex.rs:39-51](../crates/executor-core/src/sdk/stdlib/regex.rs#L39-L51)
|
||||
- **Summary:** The Rust `regex` crate is non-backtracking (no exponential ReDoS), but **compilation** is bounded only by `Regex::DEFAULT_SIZE_LIMIT` (10 MB), and the patterns are cached per-thread without an upper bound on individual pattern complexity. A long compile time per call multiplied by the LRU cache (capacity 128 *per thread*) means a script can fill the cache with maximum-complexity patterns and force the cache eviction churn to dominate. Plus: there's no input-size cap on `text` — `regex::find("a", massive_string)` is O(n) but n is uncapped.
|
||||
- **Severity:** Medium (DoS via 100 ms-per-regex × 1M ops = 27 hours; but capped by `max_operations`, so realistic ceiling is "tens of seconds before op-budget throws").
|
||||
- **Fix:** `RegexBuilder::new(pattern).size_limit(64 * 1024).dfa_size_limit(256 * 1024)` for tighter compile bounds, and add a `text.len() <= MAX_REGEX_TEXT_BYTES` (e.g., 1 MiB) guard in the call sites.
|
||||
|
||||
### F-SE-M-06 — Stack-overflow / panic in a `register_fn` closure aborts the spawn_blocking thread; the join error message reaches the HTTP body as `ExecError::Runtime("execution task panicked: ...")`
|
||||
|
||||
- **Location:** [crates/orchestrator-core/src/client.rs:211-213](../crates/orchestrator-core/src/client.rs#L211-L213), same in line 244.
|
||||
- **Summary:** If any Rhai-registered native function panics (e.g., a `Dynamic::try_cast` that the bridge code missed, an arithmetic overflow in a stdlib fn), Tokio's `JoinHandle::await` returns `Err(JoinError)`. The current handling does `format!("execution task panicked: {join_err}")` and bubbles it into `ExecError::Runtime` — which (per F-SE-H-04) flows verbatim to the HTTP body.
|
||||
- **Severity:** Medium (info disclosure; chains with F-SE-H-04).
|
||||
- **Fix:** When `JoinError::is_panic()`, log the panic detail with `tracing::error!` and return a generic `ExecError::Runtime("internal script execution error".into())`. Bonus: install a `std::panic::set_hook` in the picloud binary's startup that captures the panic site + script_id so operators have a forensic trail.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### F-SE-L-01 — `random::int(MIN, MAX)` with `min == MAX = i64::MAX` panics inside `gen_range` (panics on empty range when `low > high`, but the code guards `min > max`, not the inclusive range overflow). Verify or add a `(low_inclusive..=high_inclusive)` guard.
|
||||
- [crates/executor-core/src/sdk/stdlib/random.rs:25-35](../crates/executor-core/src/sdk/stdlib/random.rs#L25-L35).
|
||||
|
||||
### F-SE-L-02 — `time::add_seconds` / `diff_seconds` return overflow as error message including raw inputs — fine for the values themselves, but a stylistic minor leak. No real fix needed.
|
||||
|
||||
### F-SE-L-03 — `Engine::compile` (line 182-188) does NOT install the per-app `PicloudModuleResolver` — it uses `DummyModuleResolver`. That's correct for `validate()` (parse-only), but `compile()` ASTs are cached by `LocalExecutorClient::get_or_compile` and re-used by `execute_ast`. Since `execute_ast` installs the real resolver per-call, modules are resolved lazily at eval time — still correct, but worth a comment that `compile()` deliberately doesn't pre-resolve. [crates/executor-core/src/engine.rs:182-188](../crates/executor-core/src/engine.rs#L182-L188).
|
||||
|
||||
### F-SE-L-04 — No `engine.set_optimization_level(OptimizationLevel::None)` — the default `Simple` runs constant-folding at parse time. Mostly desirable, but a script with `let x = a * b * c * d * ...` (long constant chain in a const-context) takes parse-time CPU proportional to the chain length. Mitigated by F-SE-M-01's source-size cap. [crates/executor-core/src/engine.rs:304-332](../crates/executor-core/src/engine.rs#L304-L332).
|
||||
|
||||
### F-SE-L-05 — `parse_target` in [crates/executor-core/src/sdk/invoke.rs:221-242](../crates/executor-core/src/sdk/invoke.rs#L221-L242) accepts any 36-char string as a UUID candidate. Not a security issue, but on a `Name`-style invoke a 36-char tenant name unexpectedly tries UUID parsing first → script-visible error surface that varies on input length. Document the heuristic or split into explicit constructors.
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
|
||||
### F-SE-I-01 — Strong defenses in place
|
||||
- All six Rhai `set_max_*` knobs configured (operations, string size, array size, map size, call levels, expr depths).
|
||||
- `disable_symbol("print")` (debug not disabled — see F-SE-H-03).
|
||||
- No `unsafe` blocks anywhere in `executor-core` or `orchestrator-core`.
|
||||
- No `register_custom_syntax` — Rhai's parser shape is fixed.
|
||||
- No file-system / process / network primitives registered (network goes through `manager-core/src/ssrf.rs` deny-list, agent 9's scope).
|
||||
- The `Engine` struct holds an `Arc<Engine>` self-weak so `invoke()` re-uses the same `Limits` and `Services`; one script cannot mutate the engine config visible to the next.
|
||||
- Per-call rhai::Engine instance is freshly built in `execute_ast` ([engine.rs:213](../crates/executor-core/src/engine.rs#L213)) — confirmed: no shared parser state between scripts.
|
||||
- `PicloudModuleResolver` has cycle detection (in-progress stack) + acyclic-depth cap + per-app cache key + backend-error redaction.
|
||||
- `args_to_json` in invoke rejects `FnPtr` closures crossing the invoke boundary.
|
||||
|
||||
### F-SE-I-02 — Concurrency gate (`ExecutionGate`) defaults to 32, sized to deny pool starvation. Token returned on future drop, not blocking-thread completion (see F-SE-H-01 follow-on). [crates/orchestrator-core/src/gate.rs](../crates/orchestrator-core/src/gate.rs).
|
||||
|
||||
### F-SE-I-03 — `Engine::compile` accepts arbitrary source from `LocalExecutorClient::execute(source, ...)`; the orchestrator path normally uses `execute_with_identity` (cached). The non-identity `execute` path is documented as "tests + fallback" — verify it isn't reachable on public routes, otherwise F-SE-M-01 becomes a public-data-plane DoS rather than admin-only.
|
||||
|
||||
### F-SE-I-04 — Wall-clock interrupt design assumption — `LocalExecutorClient::execute` was written assuming `spawn_blocking` could be cancelled via `JoinHandle` drop; the code's own comment block at [client.rs:184-192](../crates/orchestrator-core/src/client.rs#L184-L192) admits this is wrong but accepts the trade-off. The trade-off was reasonable in v1.0 single-node MVP; at v1.1.9 with public-HTTP scripts running unauthenticated, it is the highest-impact unmitigated DoS surface in the engine.
|
||||
|
||||
---
|
||||
|
||||
## Summary recommendation
|
||||
|
||||
If a single follow-up is funded out of this report, **fix F-SE-H-01** — install `engine.on_progress` with a deadline check. The other three Highs (per-execution SDK-call cap, `debug` symbol, error-message scrubbing) are all small, well-scoped patches and should land together in the same hardening release. None of them require schema changes, and the test surface is well-defined (the executor-core test pattern already covers `respects_operation_budget`).
|
||||
122
security_audit/06_files_pathtraversal.md
Normal file
122
security_audit/06_files_pathtraversal.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Security Audit — File Storage & Path Traversal
|
||||
|
||||
**Scope:** v1.1.5 `files::*` SDK + admin API; FS layout `<PICLOUD_FILES_ROOT>/files/<app_id>/<collection>/<id[0:2]>/<id>`.
|
||||
**Reviewed files:**
|
||||
- `/home/fabi/PiCloud/crates/manager-core/src/files_service.rs`
|
||||
- `/home/fabi/PiCloud/crates/manager-core/src/files_repo.rs`
|
||||
- `/home/fabi/PiCloud/crates/manager-core/src/files_api.rs`
|
||||
- `/home/fabi/PiCloud/crates/manager-core/src/files_sweep.rs`
|
||||
- `/home/fabi/PiCloud/crates/shared/src/files.rs`
|
||||
- `/home/fabi/PiCloud/crates/executor-core/src/sdk/files.rs`
|
||||
- `/home/fabi/PiCloud/dashboard/src/routes/apps/[slug]/files/+page.svelte`
|
||||
- `/home/fabi/PiCloud/dashboard/src/lib/api.ts`
|
||||
- `/home/fabi/PiCloud/caddy/Caddyfile`
|
||||
|
||||
## Counts by severity
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| Critical | 0 |
|
||||
| High | 2 |
|
||||
| Medium | 4 |
|
||||
| Low | 4 |
|
||||
| Info | 2 |
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### High
|
||||
|
||||
#### F-FS-001 — Missing MIME allowlist enables stored XSS via `Content-Disposition: inline` on the admin download endpoint
|
||||
- **Severity:** High (storage half — rendering half is agent 7's call)
|
||||
- **Location:** `crates/manager-core/src/files_api.rs:153-161` (`get_file`), `crates/shared/src/files.rs` (`NewFile::validate`)
|
||||
- **Summary:** `get_file` echoes the script-supplied `content_type` straight into the response `Content-Type` header, paired with `Content-Disposition: inline; filename="..."`. There is no allowlist on `content_type` at the SDK boundary — `NewFile::validate` only caps its length at 127 bytes. A Rhai script can `files::create(#{ name: "x.svg", content_type: "image/svg+xml", data: <svg onload=...> })` (or `text/html`), and any logged-in operator who clicks Download in the dashboard renders the attacker payload **same-origin** under `/api/v1/admin/...`, with the picloud admin session cookie attached. That is admin-session theft.
|
||||
- **Storage-side fix:** maintain a small allowlist (`image/png|jpeg|gif|webp`, `application/pdf`, `application/octet-stream`, `text/plain`, `application/json`) at `NewFile::validate` + `FileUpdate::validate`. Anything else stored becomes `application/octet-stream` on serve, OR the admin endpoint forces `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` for non-allowlisted types.
|
||||
- **Cross-ref:** **agent 7** owns the rendering / response-header conclusion (CSP, sandbox, `nosniff`, force-attachment). Flag jointly.
|
||||
|
||||
#### F-FS-002 — Admin files API skips collection validation; underlying `head`/`get`/`list`/`delete` skip the FS-path guard too
|
||||
- **Severity:** High (defense-in-depth gap; **not** exploitable today, but one bug away from arbitrary-FS-read/write/unlink)
|
||||
- **Location:** `crates/manager-core/src/files_api.rs:78-184` (no `validate_collection`); `crates/manager-core/src/files_repo.rs:321,395` (`guard_collection` is only called from `create` and `update` — `head`, `get`, `list`, `delete` do **not** call it).
|
||||
- **Summary:** The admin endpoints accept `collection` as a path/query parameter and pass it raw to the repo. The repo's `head`/`get`/`list` only execute parametrized SQL (no immediate FS access), and `delete` only builds `final_path` and `remove_file`s *after* a matching DB row is found. **Today** this is safe because `create`/`update` validate before insert, so no row can exist with a traversal collection. **Tomorrow** it's one missed validation away from full filesystem reach. Specifically: `FsFilesRepo::get` → `read_verify_at(..., collection, id, ...)` and `FsFilesRepo::delete` → `self.final_path(app_id, collection, id)` + `std::fs::remove_file(path)` both build paths from the unvalidated collection. A future code path that inserts a row with `collection = "../../etc"` (a buggy migration, an admin restore tool, a backup loader) would immediately give cross-app file-read and unlink.
|
||||
- **Fix shape:** call `validate_files_collection` at the top of every admin endpoint (`list_files`, `get_file`, `delete_file`) AND add `Self::guard_collection(collection)?;` to `FsFilesRepo::{head, get, list, delete}`. Both layers — depth — because the audit posture for this surface is "no traversal possible regardless of caller."
|
||||
|
||||
---
|
||||
|
||||
### Medium
|
||||
|
||||
#### F-FS-003 — No per-app storage quota; one tenant can fill the host disk
|
||||
- **Severity:** Medium (cross-ref **agent 8** for the host-DoS angle)
|
||||
- **Location:** `crates/manager-core/src/files_service.rs`, `crates/manager-core/src/files_repo.rs` — no quota check anywhere.
|
||||
- **Summary:** Only the per-file cap (`PICLOUD_FILES_MAX_FILE_SIZE_BYTES`, default 100 MB) is enforced. An app can create unlimited rows of up to 100 MB each via any public Rhai script that calls `files::create`. With multi-tenant isolation as a stated goal, this is the cheapest path to host-level DoS: one anonymous public HTTP script + a loop. CLAUDE.md acknowledges per-app quotas are deferred to v1.2, but the threat surface exists today.
|
||||
- **Fix shape:** track `SUM(size_bytes) GROUP BY app_id` in a small counter table (`files_app_usage`), increment/decrement in the same tx as the `files` row mutation, reject `create`/`update` when projected usage exceeds `PICLOUD_FILES_APP_QUOTA_BYTES` (default e.g. 5 GB).
|
||||
|
||||
#### F-FS-004 — Content-Disposition filename is not RFC 5987-encoded; non-ASCII names lose data or break the header
|
||||
- **Severity:** Medium
|
||||
- **Location:** `crates/manager-core/src/files_api.rs:153-156`
|
||||
- **Summary:**
|
||||
```rust
|
||||
let disposition = format!(
|
||||
"inline; filename=\"{}\"",
|
||||
meta.name.replace('"', "").replace(['\r', '\n'], "")
|
||||
);
|
||||
```
|
||||
CRLF and `"` are stripped (good — no header injection), but no RFC 5987 `filename*=UTF-8''<pct-encoded>` form. Non-ASCII filenames (`café.pdf`, `合同.pdf`) either round-trip through a lossy ASCII strip (browsers vary), or become an invalid `filename="..."` token if any high-bit byte is preserved. Combined with `inline`, the response then renders without an obvious filename, weakening the user's ability to spot a malicious upload.
|
||||
- **Fix shape:** emit both forms — `filename="<ASCII-sanitized fallback>"; filename*=UTF-8''<percent-encoded>` — per RFC 6266 §5.
|
||||
|
||||
#### F-FS-005 — Temp file mode honors umask (typically 0644) instead of 0600
|
||||
- **Severity:** Medium (downgraded by the 0o700 shard-dir guard, but defense-in-depth misses)
|
||||
- **Location:** `crates/manager-core/src/files_repo.rs:267` — `std::fs::File::create(&tmp)`
|
||||
- **Summary:** The temp file gets default `OpenOptions::create(true).write(true).truncate(true)` mode, masked through the process umask. On a typical Linux box that's 0644. The 0o700 parent shard dir protects against world access today, but if `PICLOUD_FILES_ROOT` ever lives somewhere mounted with `nosuid,nodev` for a service user, or if the operator chowns the root to a less-restrictive group, file bytes leak.
|
||||
- **Fix shape:** on Unix, set `OpenOptionsExt::mode(0o600)` on the temp file create; then `set_permissions(0o600)` after rename to harden against the umask not applying as expected.
|
||||
|
||||
#### F-FS-006 — No symlink-aware path resolution; a stray symlink inside the root escapes
|
||||
- **Severity:** Medium (theoretical — there is no code path that creates symlinks under `PICLOUD_FILES_ROOT`, but the sweeper and the read path do not detect them)
|
||||
- **Location:** `crates/manager-core/src/files_repo.rs:282-311` (`read_verify_at`); `crates/manager-core/src/files_sweep.rs:75-111` (`walk`)
|
||||
- **Summary:** `read_verify_at` opens via `std::fs::read(&path)` which follows symlinks. The orphan sweeper's `walk` uses `entry.file_type()` (which returns the symlink type if applicable), and skips non-files — symlinks-to-files would be skipped, but symlinks-to-dirs are also skipped, OK. The bigger gap is read: if **any** code path (a future backup-restore, an operator drop-in, a docker volume mount) plants a symlink at `<root>/files/<app>/<col>/<sh>/<id>` pointing to `/etc/shadow`, a subsequent `get_file` reads it. There's no `canonicalize` + prefix check.
|
||||
- **Fix shape:** in `read_verify_at` / `write_atomic_at`, after constructing the path, `canonicalize` it and verify the canonical form starts with the canonicalized files-root; reject otherwise. For the sweeper, use `symlink_metadata` instead of `metadata` to avoid traversing.
|
||||
|
||||
---
|
||||
|
||||
### Low
|
||||
|
||||
#### F-FS-007 — Download endpoint relies on session cookie only; no signed-URL option
|
||||
- **Severity:** Low (auth is required via cookie — but the URL is unbearer-able)
|
||||
- **Location:** `crates/manager-core/src/files_api.rs:130-165`, `dashboard/src/lib/api.ts:956-957`
|
||||
- **Summary:** `api.files.downloadUrl(...)` returns a plain `/api/v1/admin/...` URL relying on the `picloud_session` cookie for auth. Authn is enforced (auth middleware + `require(... AppFilesRead)`), so it is **not** open. The only concern is operator hygiene: if an admin copies the URL and shares it (intending to share the file), the recipient gets auth-free 403 instead of the file — fine, defense-wise. No fix required; just noting.
|
||||
|
||||
#### F-FS-008 — Filename-byte-length cap of 255 doesn't account for Windows / FAT-style ceilings or POSIX `NAME_MAX` on platforms with smaller caps
|
||||
- **Severity:** Low
|
||||
- **Location:** `crates/shared/src/files.rs:27,197`
|
||||
- **Summary:** 255 bytes is fine on ext4/btrfs/xfs. On some platforms (HFS+: 255 UTF-16 code units; ZFS: usually OK), edge cases. The name is also reflected into `Content-Disposition` and the dashboard table, so the cap is more a header-hygiene concern than an FS one.
|
||||
|
||||
#### F-FS-009 — `head`/`get`/`list` don't reject NUL bytes in `collection` before SQL
|
||||
- **Severity:** Low (Postgres rejects NUL in TEXT, so the SQL fails cleanly with a 500 — but the user sees "internal error" instead of "invalid collection")
|
||||
- **Location:** `crates/manager-core/src/files_repo.rs:347-516`
|
||||
- **Summary:** Adding `Self::guard_collection` (see F-FS-002) gives a precise 4xx instead of a 5xx for `\0` in collection names.
|
||||
|
||||
#### F-FS-010 — `Content-Length` from `bytes.len()` is set, but no `Cache-Control` / `X-Content-Type-Options` on the download
|
||||
- **Severity:** Low
|
||||
- **Location:** `crates/manager-core/src/files_api.rs:158-164`
|
||||
- **Summary:** Missing `X-Content-Type-Options: nosniff` lets the browser MIME-sniff a stored `text/plain` payload into `text/html` and render JS. Pairs with F-FS-001 as the response-header hardening pass.
|
||||
|
||||
---
|
||||
|
||||
### Info
|
||||
|
||||
#### F-FS-011 — File IDs are server-side `Uuid::new_v4()`; safe from caller-controlled bytes
|
||||
- **Location:** `crates/manager-core/src/files_repo.rs:322` and `:268` (in-memory test). UUIDs are formatted via `to_string()` (lowercase hex + dashes, never traversal-shaped). Good.
|
||||
|
||||
#### F-FS-012 — Atomic write protocol is correct; delete order is correct
|
||||
- **Location:** `files_repo.rs:217-277` and `:431-473`. Temp + fsync + rename + dir-fsync; row delete inside tx then unlink after commit. The "metadata gone, bytes still on disk" leak is bounded (rare crash window after the tx commit) and is reclaimable by a future reconcile sweep (currently only `*.tmp.*` orphans are reaped by `files_sweep.rs`). No bug — just noting the design is sound and the residual leak is documented.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references for other agents
|
||||
|
||||
- **Agent 7 (XSS / rendering):** F-FS-001 — the storage side allows arbitrary `content_type`; rendering side owns CSP / `X-Content-Type-Options: nosniff` / forced `attachment`. Joint sev = High.
|
||||
- **Agent 8 (DoS / resource):** F-FS-003 (no per-app quota) — same concern they may have raised for `kv::set` / `docs::*` / `pubsub` / `queue` unbounded payloads (cf. F-S-001 in AUDIT.md). Files has a per-row cap, but no per-app aggregate.
|
||||
- **Agent 10 (CLI / config files):** CLI credentials are `0o600` (verified, `crates/picloud-cli/src/config.rs:98,107`); no finding from the FS angle.
|
||||
|
||||
## Most-important takeaway
|
||||
|
||||
Today nothing escapes `PICLOUD_FILES_ROOT` — `create` and `update` guard `collection`, and FS paths are only ever built from server-generated UUIDs and validated collections. The exposure that is **live** is F-FS-001: a public-route script can stash `text/html` or `image/svg+xml` content that the operator's browser renders same-origin under the admin session cookie. The exposure that is **one mistake away** is F-FS-002: every read/delete path through the admin API trusts collection-validation done elsewhere, and the repo's `head`/`get`/`list`/`delete` don't have the belt-and-suspenders guard that `create`/`update` carry. Add the missing `validate_files_collection` calls at the admin layer and `guard_collection` calls at the repo layer, and the surface becomes traversal-proof regardless of caller. The MIME allowlist + `Content-Disposition: attachment` for non-image types closes the stored-XSS class jointly with agent 7's response-header pass.
|
||||
116
security_audit/07_http_cors_csrf_xss.md
Normal file
116
security_audit/07_http_cors_csrf_xss.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Audit 07 — HTTP headers, CORS, CSRF, frontend XSS
|
||||
|
||||
Scope: Caddy (dev + prod), orchestrator/manager Axum routers, SvelteKit dashboard, file download surface. Date: 2026-06-11.
|
||||
|
||||
## Summary
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| Critical | 2 |
|
||||
| High | 3 |
|
||||
| Medium | 4 |
|
||||
| Low | 3 |
|
||||
| Info | 2 |
|
||||
|
||||
The platform ships with effectively no defense-in-depth at the HTTP layer: no CSP, no `X-Content-Type-Options`, no `X-Frame-Options`, no HSTS, no Referrer-Policy, no Permissions-Policy, no `Cache-Control: no-store` on admin responses. Both `caddy/Caddyfile` and `caddy/Caddyfile.prod` are bare reverse proxies; the dashboard's inner Caddy (`docker/dashboard.Dockerfile` line 28) is equally bare. This is harmful on its own and compounds the application-layer issues below.
|
||||
|
||||
---
|
||||
|
||||
## C07-01 (Critical) — Same-origin CSRF on admin API via `SameSite=Lax` cookie + user-route surface
|
||||
File: `crates/manager-core/src/auth_api.rs:229`, `crates/manager-core/src/auth_middleware.rs:91, 359`, `crates/orchestrator-core/src/api.rs:609–623`.
|
||||
|
||||
`/auth/login` sets `picloud_session=<raw_token>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=...`. The auth middleware then accepts the cookie as a fallback to `Authorization: Bearer`. User scripts can return arbitrary HTML on **the same origin** as `/api/v1/admin/*` (user routes are bound on the same host by design, see `Caddyfile` line 62 catch-all). `SameSite=Lax` does NOT block same-origin requests, so:
|
||||
|
||||
- A logged-in admin visits an attacker-controlled script at e.g. `https://admin.example.com/evil` (a user route the attacker created — and any owner/admin/member with `AppScriptsWrite` can do so).
|
||||
- The page issues a `<form method="POST" action="/api/v1/admin/apps/<id>/admins" enctype="text/plain">…</form>` or `fetch('/api/v1/admin/...')` — both fire **same-origin** with the cookie attached.
|
||||
- The admin endpoint executes the action as the victim.
|
||||
|
||||
The dashboard's `lib/api.ts` sends bearer tokens (line 495), but the server still accepts cookies as a parallel auth path. Removing the cookie path on `/api/v1/admin/*` (Authorization-only) is the cleanest fix; alternatively gate cookie auth behind a CSRF token / SameSite=Strict, and reject `Content-Type: text/plain` POSTs. Same-origin attack means *no preflight* and no Origin/Referer check stops it.
|
||||
|
||||
Recommendation: drop the cookie auth path on admin endpoints entirely (the dashboard already uses bearer tokens), OR enforce `Sec-Fetch-Site: same-origin` and a CSRF synchronizer token. `SameSite=Lax` is insufficient given the orchestrator co-hosts attacker-controlled HTML on the same origin.
|
||||
|
||||
## C07-02 (Critical) — Stored XSS on admin origin via uploaded files served `inline` with no `nosniff`
|
||||
File: `crates/manager-core/src/files_api.rs:130–164`.
|
||||
|
||||
`GET /apps/{id}/files/{collection}/{file_id}` streams blob bytes with `Content-Disposition: inline; filename="<name>"` and the user-supplied `Content-Type`. There is no `X-Content-Type-Options: nosniff`, no CSP, and no forced `attachment` disposition. A member with `AppFilesWrite` (or anyone whose script writes a file in v1.1.5) can upload `evil.html` with `Content-Type: text/html`; opening the download URL — which lives on the admin origin — renders attacker JS as the logged-in admin. Result: full session hijack / privilege escalation.
|
||||
|
||||
**Ownership note vs. agent 6:** agent 6 likely covers the upload-side (content-type validation, magic bytes). The **response-side fix is owned here**: serve all download responses with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors 'none'`, and consider serving file blobs from a distinct hostname (`files.<domain>`). Whoever fixes the storage layer must coordinate; the response headers are non-negotiable.
|
||||
|
||||
---
|
||||
|
||||
## H07-03 (High) — No Content-Security-Policy anywhere
|
||||
Files: `caddy/Caddyfile.prod`, `docker/dashboard.Dockerfile:28`.
|
||||
|
||||
The dashboard has no CSP — any reflected or stored XSS escalates immediately. Recommended starter CSP for `/admin/*`:
|
||||
```
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
```
|
||||
For `/api/v1/admin/*` and `/api/v1/execute/*`: `default-src 'none'; frame-ancestors 'none'`. User-route responses should *not* get a CSP injected (scripts control their own headers — that's by design), but the admin SPA absolutely should.
|
||||
|
||||
## H07-04 (High) — No `X-Frame-Options` / `frame-ancestors`: dashboard clickjackable
|
||||
Files: same as above. Combined with the cookie session, an attacker can iframe `/admin/...` and overlay clickjack UI to trigger admin actions. `Content-Security-Policy: frame-ancestors 'none'` (covered by H07-03) is the modern fix; legacy fallback is `X-Frame-Options: DENY`.
|
||||
|
||||
## H07-05 (High) — No HSTS in production Caddyfile
|
||||
File: `caddy/Caddyfile.prod` (lines 11–50). Auto-HTTPS is on but `Strict-Transport-Security` is not emitted. First-visit downgrade and TLS-stripping attacks remain possible. Add `header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"` inside the prod site block (after carefully scoping the includeSubDomains decision).
|
||||
|
||||
---
|
||||
|
||||
## M07-06 (Medium) — No `X-Content-Type-Options: nosniff`
|
||||
Universally missing. Browsers will sniff content types on any response (including JSON responses from `/api/v1/admin/*`). Combined with C07-02 this is acute, but every response surface needs `nosniff`.
|
||||
|
||||
## M07-07 (Medium) — No `Referrer-Policy`
|
||||
Default `strict-origin-when-cross-origin` is okay-ish, but the admin app should be `Referrer-Policy: no-referrer` or `same-origin` to avoid leaking `/admin/scripts/<id>/...` URLs to third parties when an admin clicks an outbound link in a user-supplied log entry.
|
||||
|
||||
## M07-08 (Medium) — No `Permissions-Policy`
|
||||
Geolocation, USB, camera, microphone, payment, etc. are unrestricted. Recommend `Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()` on `/admin/*`.
|
||||
|
||||
## M07-09 (Medium) — Admin API responses are cacheable
|
||||
No `Cache-Control: no-store` is emitted on `/api/v1/admin/*` JSON responses. Shared proxies / forward caches could retain session-keyed data. Add a layer that injects `Cache-Control: no-store, private` for admin endpoints.
|
||||
|
||||
---
|
||||
|
||||
## L07-10 (Low) — Logout is CSRF-able
|
||||
`/auth/logout` accepts the cookie and clears the session with no anti-CSRF token. Effect is forced sign-out — annoyance, not impact.
|
||||
|
||||
## L07-11 (Low) — Session cookie not `__Host-` prefixed
|
||||
`picloud_session` is not `__Host-picloud_session`. The `__Host-` prefix would enforce `Path=/`, `Secure`, no `Domain` (already true) and provide subdomain isolation if a sibling subdomain ever exists.
|
||||
|
||||
## L07-12 (Low) — Dashboard token in `localStorage`
|
||||
`dashboard/src/lib/auth.ts:27,37` keeps the bearer token in `localStorage`. XSS-survivability is poor: any XSS exfiltrates it. With the cookie path also active this is doubly bad. Once C07-01 is resolved by going Authorization-only, consider an in-memory token + refresh approach. Not raising higher because the CSP gap (H07-03) is the upstream issue.
|
||||
|
||||
---
|
||||
|
||||
## I07-13 (Info) — No `{@html}`, no `innerHTML`, no `eval`/`Function()` in dashboard
|
||||
Verified by grep across `dashboard/src/`. Email invite `body_template` is server-side `String::replace("{link}", …)` (`crates/manager-core/src/users_service.rs:745`) and emails are plain-text; no HTML rendering pathway. CodeMirror is used purely client-side for the script editor; no widget evaluates Rhai source as JS. No external CDN `<script src=…>` in `app.html`.
|
||||
|
||||
## I07-14 (Info) — No CORS configuration emits, but none is needed yet
|
||||
There is no `tower-http::cors` use anywhere in `crates/`. The dashboard fetches same-origin only. Browsers will block cross-origin reads against `/api/v1/admin/*` by default. This is fine for MVP. **Caveat:** if/when external clients are introduced (the `clients/typescript/` SDK in non-browser contexts is unaffected), introduce a strict allow-list — never reflect `Origin` with `Access-Control-Allow-Credentials: true`. Document this as a v1.2+ requirement.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
- Agent 6 (files / path traversal): C07-02 is a duplicate hit. **I own the response-side recommendation** (`Content-Disposition: attachment`, `nosniff`, sandboxed CSP, ideally a separate `files.<domain>` origin). Agent 6 owns upload-side validation (allow-list MIME, sniff content, reject HTML/SVG). Both fixes are required; neither alone suffices.
|
||||
- Agent 1 (auth/session): SSE handler accepts `?token=<bearer>` (`crates/orchestrator-core/src/realtime_api.rs:97,110`), which will leak into access logs. Out of my scope; flagging for them.
|
||||
- Agent 4 (injection): `Set-Cookie` header includes raw token value with no escaping (`crates/manager-core/src/auth_api.rs:228`) — the token is ASCII by construction (`generate_session_token`), but if that ever changes, the cookie builder needs hardening.
|
||||
|
||||
## Suggested Caddy snippet
|
||||
Add to both Caddyfiles inside each handle (or once at the top using `header`):
|
||||
```
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "no-referrer"
|
||||
Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=()"
|
||||
-Server
|
||||
}
|
||||
@admin path /admin*
|
||||
header @admin {
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||
Cache-Control "no-store, private"
|
||||
}
|
||||
@adminapi path /api/v1/admin/*
|
||||
header @adminapi {
|
||||
Content-Security-Policy "default-src 'none'; frame-ancestors 'none'"
|
||||
Cache-Control "no-store, private"
|
||||
}
|
||||
```
|
||||
User-route responses should NOT receive a CSP from Caddy — scripts own their headers (`crates/orchestrator-core/src/api.rs:609`).
|
||||
454
security_audit/08_dos_resource.md
Normal file
454
security_audit/08_dos_resource.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# Security audit — DoS, rate limiting & resource exhaustion
|
||||
|
||||
**Audit date:** 2026-06-11
|
||||
**Scope:** PiCloud at `main` (post-v1.1.9, pre-v1.2). Single-node MVP.
|
||||
**Reviewer:** Subagent 08 of 10 (parallel security audit).
|
||||
**Threat model:** Solo-dev / Pi-class hardware. A single anonymous attacker
|
||||
or one noisy authenticated tenant should not OOM the host, fill the disk,
|
||||
or starve the executor for other tenants in minutes.
|
||||
|
||||
## Counts by severity
|
||||
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| Critical | 0 |
|
||||
| High | 5 |
|
||||
| Medium | 7 |
|
||||
| Low | 6 |
|
||||
| Info | 3 |
|
||||
|
||||
## Verified / no-finding
|
||||
|
||||
These were investigated and judged adequate for the threat model:
|
||||
|
||||
- **Concurrent script execution cap.** `ExecutionGate` uses a single
|
||||
`tokio::sync::Semaphore` with `try_acquire_owned()` and translates
|
||||
`NoPermits`/`Closed` into `AcquireError::Overloaded { retry_after_secs: 1 }`,
|
||||
which `IntoResponse` for `ApiError` maps to HTTP 503 with `Retry-After: 1s`
|
||||
(`crates/orchestrator-core/src/api.rs:712-721`). Default 32, env-tunable
|
||||
via `PICLOUD_MAX_CONCURRENT_EXECUTIONS`. Both sync HTTP and the dispatcher
|
||||
share the same gate so the dispatcher arms back off correctly when sync
|
||||
HTTP is saturated. **Note on fairness:** `try_acquire` is non-blocking so
|
||||
"fairness" is irrelevant — there is no queue, callers get 503 immediately.
|
||||
- **Postgres pool default size.** `init_db` in
|
||||
`crates/picloud/src/lib.rs:603-619` now defaults to
|
||||
`DEFAULT_DB_MAX_CONNECTIONS = 32` and is env-tunable via
|
||||
`PICLOUD_DB_MAX_CONNECTIONS`, matching the gate. (F-P-003 from the prior
|
||||
audit was remediated.) Acquire timeout 5s.
|
||||
- **Per-message SDK service caps.** `kv_service`, `docs_service`,
|
||||
`pubsub_service`, `queue_service`, `files_service`, `secrets_service`
|
||||
all enforce a per-message JSON-encoded byte cap (256 KB default, except
|
||||
files at 100 MB) **before** authz. `kv_service.rs:141`, `docs_service.rs:93`,
|
||||
`pubsub_service.rs:178`, `queue_service.rs:82`. (F-S-001 from the prior
|
||||
audit was remediated.)
|
||||
- **Outbound HTTP defaults (SDK http::request).** 30s default timeout,
|
||||
60s hard ceiling; 10s connect timeout; 10 MB request/response body cap;
|
||||
5 redirect default, 10 ceiling. SSRF deny-list on by default. See
|
||||
`crates/manager-core/src/http_service.rs:41-53`.
|
||||
- **Email outbound rate limit.** `EmailRateLimiter` (token bucket +
|
||||
per-(app, recipient) burst + 200/day per-app cap) gates `users::*` email
|
||||
surfaces (`crates/manager-core/src/users_service.rs:139-187`). F-S-002
|
||||
remediated.
|
||||
- **Outbox claim shape.** Dispatcher claims at most `CLAIM_BATCH = 8`
|
||||
rows per tick (100 ms default) with `FOR UPDATE SKIP LOCKED`, gates
|
||||
every dispatch through the same `ExecutionGate`, and reschedules on
|
||||
permit refusal — bounded by design. See
|
||||
`crates/manager-core/src/dispatcher.rs:69,193-216,482-491`.
|
||||
- **Outbox row deletion.** Outbox rows are deleted on success/dead-letter
|
||||
inline (`OutboxRepo::delete`), so the table doesn't accumulate
|
||||
completed rows under normal load. Bloat risk surveyed below as a
|
||||
Medium (stuck rows).
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H-1 — No Axum default body limit; the only inbound 503-path is the
|
||||
data-plane user route — all admin/email/webhook handlers fall under Axum's
|
||||
default 2 MB and the `/api/v1/execute/{id}` handler reads `body: Bytes`
|
||||
with no explicit override.
|
||||
|
||||
- **Severity:** High
|
||||
- **Location:**
|
||||
`crates/orchestrator-core/src/api.rs:106-123` (execute_by_id, `body: Bytes`,
|
||||
no `DefaultBodyLimit`);
|
||||
`crates/manager-core/src/email_inbound_api.rs:131-136` (inbound webhook,
|
||||
`body: Bytes`);
|
||||
`crates/picloud/src/lib.rs:587-593` (router composition — no
|
||||
`RequestBodyLimitLayer`); admin JSON handlers (`Json<…>` extractors at
|
||||
`auth_api.rs:74`, `admin_users_api.rs`, `users_admin_api.rs`, etc.).
|
||||
- **Summary:** Axum's `Bytes`/`Json` extractors default to 2 MB. `Caddy`
|
||||
has no `request_body { max_size … }` directive in either `Caddyfile`
|
||||
or `Caddyfile.prod` (just unrestricted `reverse_proxy`), so any
|
||||
request up to **10 GB** (Caddy's hard ceiling) can hit picloud. Only
|
||||
the data-plane user-route handler (`user_route_handler` in
|
||||
`api.rs:219`) explicitly bounds the body at 10 MiB; everything else
|
||||
rides Axum's default.
|
||||
- For `/email-inbound/{app_id}/{trigger_id}` the body is `Bytes`
|
||||
with no override — so the 2 MB ceiling is the only thing standing
|
||||
between an anonymous attacker and a 2 MB JSON-parse fork-bomb per
|
||||
request. With no IP rate limit, one client can sustain hundreds of
|
||||
MB/s of CPU-bound `serde_json::from_slice` and HMAC verification
|
||||
work, saturating one execution worker indefinitely. This is the
|
||||
canonical "no rate limit on a public unauthenticated endpoint"
|
||||
finding — see also H-3.
|
||||
- For `POST /api/v1/execute/{id}` Axum's 2 MB default applies, but
|
||||
the request lands at the executor regardless of script size,
|
||||
meaning a script with `body_limit_kb = 32` still pays the
|
||||
serialization cost for a 2 MB body.
|
||||
- **Fix shape:**
|
||||
- Add a global `axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024)`
|
||||
layer at the router root (already implicit, but should be explicit
|
||||
+ lower the default to ~1 MB).
|
||||
- Add per-route higher limits where intentional (email inbound is
|
||||
typically tens of KB; lower it to 1 MB explicitly).
|
||||
- Caddy: `request_body { max_size 5MB }` in both Caddyfiles.
|
||||
|
||||
### H-2 — Login endpoint has no rate limit; Argon2id is the work multiplier
|
||||
- **Severity:** High
|
||||
- **Location:** `crates/manager-core/src/auth_api.rs:74-167`.
|
||||
- **Summary:** `POST /api/v1/admin/auth/login` is reachable
|
||||
unauthenticated, and on every request it does a username lookup +
|
||||
Argon2id verify (m=19456 KiB, t=2 default Argon2id params) via
|
||||
`spawn_blocking`. There is no per-IP, per-username, or process-wide
|
||||
cap on requests / failed attempts. Argon2 is ~50–150 ms per attempt
|
||||
on Pi-class hardware; an anonymous attacker with 32 in-flight HTTP
|
||||
requests can keep every blocking-thread worker pinned on Argon2
|
||||
forever, **and** can attempt ≈600 password guesses/sec process-wide.
|
||||
Username enumeration is correctly defended (dummy-hash path) so the
|
||||
same Argon2 cost is paid on bad usernames — which means the cost
|
||||
inflicts on the defender for every miss.
|
||||
- **Threat:** Combined with H-1 (no body limit on `Json<LoginRequest>`)
|
||||
this is the cheapest CPU-DoS in the system. A laptop attacker can
|
||||
fully saturate a Raspberry Pi's executor in seconds.
|
||||
- **Fix shape:** add a sliding-window login rate limiter keyed on
|
||||
`(client_ip, username)` (e.g. 5 attempts / 60s, lockout for 5 min
|
||||
after 20), and a global `Semaphore` cap (e.g. 4 concurrent in-flight
|
||||
Argon2 verifies) so admin auth can't starve the executor's
|
||||
`spawn_blocking` pool. Memory only — Postgres-backed counters add a
|
||||
round-trip. The existing `EmailRateLimiter` pattern in
|
||||
`users_service.rs:139` is the shape to copy.
|
||||
|
||||
### H-3 — Email-inbound webhook has no IP rate limit; HMAC is optional
|
||||
- **Severity:** High
|
||||
- **Location:** `crates/manager-core/src/email_inbound_api.rs:131-191`,
|
||||
mounted **outside** `require_authenticated` at
|
||||
`crates/picloud/src/lib.rs:571-575`.
|
||||
- **Summary:** `POST /api/v1/email-inbound/{app_id}/{trigger_id}` is
|
||||
public. When the trigger has an `inbound_secret` configured, HMAC
|
||||
verification is correct (timestamp-bound, ±5 min, in-memory replay
|
||||
dedup). But:
|
||||
1. If the operator did NOT configure an `inbound_secret` for the
|
||||
trigger, **no signature is required at all** (see the
|
||||
`if let (Some(ct), Some(nonce)) = …` guard at line 150). An
|
||||
anonymous attacker who knows the trigger UUID can enqueue an
|
||||
unbounded number of outbox rows + executor invocations per
|
||||
second, bypassing every other rate limit.
|
||||
2. With a secret configured, attackers still pay no cost for sending
|
||||
garbage — the receiver must decrypt the secret from the secret
|
||||
store (one DB call + master-key open) and HMAC-verify per request
|
||||
**before** rejecting. No `(app_id, trigger_id)` invalid-signature
|
||||
bucket. So an attacker who learns the URL but not the secret can
|
||||
still pin a worker thread on secret-decrypt + HMAC.
|
||||
3. The body is `Bytes` without a `DefaultBodyLimit` override — falls
|
||||
under Axum's 2 MB cap (see H-1).
|
||||
- **Threat:** A single anonymous HTTP client knowing only an `app_id`
|
||||
+ a `trigger_id` (often leaked through public docs / TLS cert
|
||||
transparency logs / mis-configured provider dashboards) can enqueue
|
||||
enough outbox rows to fill the dispatcher batch every tick. With
|
||||
retries, one row → up to `retry_max_attempts` (default 3) executions.
|
||||
Outbox grows unbounded; disk fills.
|
||||
- **Fix shape:** require HMAC at the trigger-row level (DB CHECK that
|
||||
email triggers MUST have a secret); add per-`(app_id, trigger_id)`
|
||||
token-bucket gating both `inbound_secret_missing` (drop fast) and
|
||||
`bad_signature` paths; add a `DefaultBodyLimit::max(1 MB)` to the
|
||||
router merge in `lib.rs`.
|
||||
|
||||
### H-4 — No per-app cap on trigger / route / cron registration
|
||||
- **Severity:** High
|
||||
- **Location:**
|
||||
`crates/manager-core/src/triggers_api.rs:127-140` (create endpoints),
|
||||
`crates/manager-core/src/route_admin.rs:160` (create_route);
|
||||
no counterpart `MAX_TRIGGERS_PER_APP` / `MAX_ROUTES_PER_APP` constant
|
||||
in the codebase.
|
||||
- **Summary:** An authenticated app developer can register an
|
||||
unbounded number of triggers (KV, docs, files, pubsub, cron, email,
|
||||
queue, dead-letter) and routes. Two amplifications follow:
|
||||
1. **Cron storm.** `cron_scheduler::tick` SELECTs **all** enabled
|
||||
cron rows in one transaction (`fetch_all`, no `LIMIT`), iterates
|
||||
in Rust, and inserts one outbox row per due trigger
|
||||
(`cron_scheduler.rs:117-167`). 10 000 cron triggers all set to
|
||||
`*/1 * * * * *` (every second) → 10 000 outbox rows per second,
|
||||
each becoming a 100ms dispatcher claim of 8 rows + 32 concurrent
|
||||
executions. The dispatcher backs up; outbox bloats; Postgres
|
||||
`next_attempt_at` index degrades.
|
||||
2. **Pubsub fan-out.** `pubsub_repo::fan_out_publish` SELECTs all
|
||||
enabled `pubsub` triggers for the app, filters in Rust on
|
||||
`topic_matches`, inserts one outbox row per match in a single
|
||||
transaction (`pubsub_repo.rs:81-115`). N triggers subscribed to
|
||||
the same topic → N writes per publish.
|
||||
3. **KV / docs / files fan-out.** Same pattern in
|
||||
`outbox_event_emitter.rs:88-103` — one outbox row per matching
|
||||
trigger per mutation.
|
||||
- **Threat:** A single tenant with `AppTriggerCreate` capability can
|
||||
register thousands of triggers and bring the whole node down by
|
||||
pubsub-publishing one message; or fill disk by cron-firing.
|
||||
Cross-tenant impact via shared dispatcher.
|
||||
- **Fix shape:** per-app trigger / route caps (e.g. 100 each, env-
|
||||
tunable) enforced in the *_api create handlers; per-publish fan-out
|
||||
cap (e.g. 100 matched triggers per publish, then dead-letter the
|
||||
excess with a clear error); cron-scheduler tick LIMIT clause
|
||||
(`LIMIT 1000`) so a misbehaving app can't ride the whole tick budget.
|
||||
|
||||
### H-5 — Cron-scheduler tick is unbounded `fetch_all`; one slow row blocks all
|
||||
- **Severity:** High
|
||||
- **Location:** `crates/manager-core/src/cron_scheduler.rs:115-173`.
|
||||
- **Summary:** Every cron tick (default 30s) opens a transaction,
|
||||
SELECTs every enabled cron row with `FOR UPDATE OF d SKIP LOCKED`,
|
||||
computes `next_due` in Rust, and INSERTs an outbox row + UPDATEs
|
||||
`last_fired_at` per due row — **all serially inside one transaction**.
|
||||
Three concrete failure modes:
|
||||
1. **Long-running tx.** With N triggers and the per-row cost ~1ms,
|
||||
a few thousand cron rows make the tick exceed the tick interval,
|
||||
pile up overlapping transactions, and hold row-locks on
|
||||
`cron_trigger_details` for seconds — blocking dashboard reads
|
||||
of the same table.
|
||||
2. **No LIMIT.** A malicious or buggy tenant registering 100 000
|
||||
cron triggers turns every tick into a multi-second scan +
|
||||
INSERT-bomb.
|
||||
3. **All-or-nothing commit.** One serialization failure (very
|
||||
possible on Pg under load) rolls back every fire in the tick.
|
||||
- **Fix shape:** add `LIMIT 1000` to the cron `fetch_all`; break the
|
||||
per-tick work into smaller transactions; require a positive
|
||||
`tick_interval_ms` floor *above* 1s (already floored, but the
|
||||
per-row work is what's unbounded, not the tick).
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M-1 — Outbox has no time-based retention sweep
|
||||
- **Severity:** Medium
|
||||
- **Location:** `crates/manager-core/src/outbox_repo.rs` (no
|
||||
`prune_expired` / `delete_older_than`);
|
||||
`crates/manager-core/src/gc.rs` (sweeps dead_letters + abandoned +
|
||||
app-user tokens, but NOT outbox).
|
||||
- **Summary:** Outbox rows are deleted inline on success or when
|
||||
exceeded; but stuck rows (`claimed_at` set but never released after
|
||||
a process crash mid-dispatch, or rows whose `trigger_depth >
|
||||
max_trigger_depth` slip past) accumulate. With no GC sweep, a
|
||||
bug-induced leak silently bloats the outbox table. On Pi-class
|
||||
Postgres this becomes apparent after weeks, not hours, so Medium.
|
||||
- **Fix shape:** `spawn_outbox_gc` mirroring the dead_letter pattern,
|
||||
pruning rows with `claimed_at < NOW() - INTERVAL '1 hour'`
|
||||
(orphaned claims) and rows past a 30-day retention.
|
||||
|
||||
### M-2 — No SSE subscriber cap per IP / app
|
||||
- **Severity:** Medium
|
||||
- **Location:** `crates/orchestrator-core/src/realtime_api.rs:85,
|
||||
94-159`.
|
||||
- **Summary:** `GET /realtime/topics/{topic}` is gated by topic-token
|
||||
auth, but once authorized the subscriber stays connected indefinitely
|
||||
(heartbeat keepalive). No process-wide / per-IP / per-app cap on
|
||||
active subscriptions. A leaked token plus N connections from one
|
||||
client lets the attacker pin N hyper tasks + N `broadcast::Receiver`
|
||||
slots, each holding a `tokio::sync::broadcast` channel reference.
|
||||
Per topic the broadcast channel buffers `capacity` items; with many
|
||||
topics that's many MB of resident state.
|
||||
- **Fix shape:** per-IP cap (e.g. 4 active SSE connections per remote
|
||||
address), process-wide cap (e.g. 1024), and per-app cap
|
||||
(e.g. 64). 503 on overflow.
|
||||
|
||||
### M-3 — No global cap on in-flight HTTP connections / slowloris exposure
|
||||
- **Severity:** Medium
|
||||
- **Location:** `crates/picloud/src/main.rs:79-83`
|
||||
(`TcpListener::bind` then `axum::serve` with default config).
|
||||
- **Summary:** No `set_keepalive`, no `set_nodelay`, no read/write
|
||||
header timeout, no concurrent-connection cap. Hyper defaults are
|
||||
permissive — slowloris-style attackers can hold thousands of
|
||||
half-open TLS handshakes and read-byte-per-second connections.
|
||||
Caddy fronts in prod and mitigates somewhat, but the dev/single-
|
||||
node deployment has no defense.
|
||||
- **Fix shape:** wrap the `TcpListener` with a per-connection
|
||||
`tower::limit::ConcurrencyLimit` (e.g. 256 concurrent connections);
|
||||
set `tcp_keepalive` + `http2_keep_alive_interval` on the hyper
|
||||
builder; set `header_read_timeout = 10s`. Also document a Caddy
|
||||
`servers { timeouts { read_header 10s read 30s write 30s } }` block
|
||||
in both Caddyfiles.
|
||||
|
||||
### M-4 — No per-app KV / docs / files row-count or byte quota
|
||||
- **Severity:** Medium
|
||||
- **Location:** repo-side; no `app_quotas` table or check in any
|
||||
`*_repo::insert`.
|
||||
- **Summary:** Per-row caps exist (256 KB / 100 MB) but per-app
|
||||
totals are unbounded. One tenant can store millions of KV rows or
|
||||
fill `PICLOUD_FILES_ROOT` to disk-full. Defense in depth: the
|
||||
CLAUDE.md note ("per-app quotas deferred to v1.2") explicitly
|
||||
acknowledges this; downgrading the severity reflects "documented
|
||||
v1.2 work" but it is still a real DoS today on a shared instance.
|
||||
- **Fix shape:** `app_quotas` table with `bytes_used` columns per
|
||||
service, updated transactionally on insert/update, checked at
|
||||
service-entry. Or a soft cap (warn + log) for v1.1.x; hard quota
|
||||
for v1.2.
|
||||
|
||||
### M-5 — Dispatcher retry storm: HTTP-async retries up to `retry_max_attempts` with no per-target circuit breaker
|
||||
- **Severity:** Medium
|
||||
- **Location:** `crates/manager-core/src/dispatcher.rs:885-905`,
|
||||
`trigger_config.rs:83`.
|
||||
- **Summary:** Async HTTP outbox rows retry per `TriggerConfig`
|
||||
default `retry_max_attempts = 3` with exponential backoff. No
|
||||
circuit breaker per `(app_id, script_id)` or per outbound host —
|
||||
a script that always fails fires 3 invocations per event for as
|
||||
long as events arrive. With pubsub fan-out from H-4, one
|
||||
publish → N triggers × 3 retries = `3N` executor invocations.
|
||||
- **Fix shape:** per-`(app_id, script_id)` failure counter with a
|
||||
configurable circuit-break threshold (e.g. 50% failures over 60s →
|
||||
pause for 5 min, write a `dead_letters` row).
|
||||
|
||||
### M-6 — Queue depth is unbounded; per-app queue count is unbounded
|
||||
- **Severity:** Medium
|
||||
- **Location:** `crates/manager-core/src/queue_service.rs:67-118`
|
||||
(enqueue), `crates/manager-core/src/queue_repo.rs:264-322`
|
||||
(depth uses `COUNT(*)`).
|
||||
- **Summary:** `queue::enqueue` has a per-message 256 KB cap but no
|
||||
per-queue depth cap. A loop in a public-HTTP script can fill a
|
||||
queue with millions of messages × 256 KB = GB of JSONB; combined
|
||||
with `queue::depth` being a full scan (F-P-006, still open per
|
||||
AUDIT.md remediation state), every depth call magnifies the
|
||||
attack.
|
||||
- **Fix shape:** `PICLOUD_QUEUE_MAX_DEPTH_PER_QUEUE` (e.g. 10 000) +
|
||||
`PICLOUD_QUEUE_MAX_QUEUES_PER_APP` (e.g. 100).
|
||||
|
||||
### M-7 — Trigger fan-out from KV/docs/files mutations writes outbox row per matching trigger inline (sync), with no per-event cap
|
||||
- **Severity:** Medium
|
||||
- **Location:**
|
||||
`crates/manager-core/src/outbox_event_emitter.rs:88-103,
|
||||
142-160, 200-…` (similar pattern).
|
||||
- **Summary:** Each `kv::set` matching N triggers inserts N outbox
|
||||
rows inside the calling request's transaction. No per-event cap
|
||||
on N. Combined with H-4 (unbounded trigger registration), a
|
||||
single `kv::set` can stall a request for seconds while writing
|
||||
thousands of rows. Pubsub fan-out (`pubsub_repo.rs:81-117`) is
|
||||
the same shape.
|
||||
- **Fix shape:** cap `matches.len()` at e.g. 100; reject with a
|
||||
`PolicyTooManyMatches` if exceeded so the developer notices.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L-1 — Login attempts are not logged for forensics
|
||||
- **Severity:** Low
|
||||
- **Location:** `crates/manager-core/src/auth_api.rs:74-167`.
|
||||
- **Summary:** Failed login attempts are swallowed by the
|
||||
`invalid_credentials()` return path with no structured log line, no
|
||||
counter, no `auth_audit` row. After H-2 is fixed, the rate-limit
|
||||
decisions will need observability hooks; preempt that.
|
||||
- **Fix shape:** `tracing::warn!(client_ip, username, "admin login
|
||||
failed")` (with username **before** the lookup so it can't leak
|
||||
whether the user exists via what's logged).
|
||||
|
||||
### L-2 — `tracing::info!` JSON output is unlimited and unrotated
|
||||
- **Severity:** Low
|
||||
- **Location:** `crates/picloud/src/main.rs:222-227`.
|
||||
- **Summary:** `init_tracing` writes to stdout in JSON. Operators
|
||||
running outside docker (where journald handles rotation) won't have
|
||||
automatic rotation; logs eat the disk over weeks. No `tracing_appender`
|
||||
rolling file appender wired in.
|
||||
- **Fix shape:** document the journald / `docker logs --max-size`
|
||||
expectation, or wire `tracing_appender::rolling::daily` behind an env
|
||||
var so the no-supervisor case is also safe.
|
||||
|
||||
### L-3 — `attach_principal_if_present` runs Argon2 verify on every API-key prefix candidate on every authed request
|
||||
- **Severity:** Low (already flagged at High in F-P-002; downgraded
|
||||
here because pretty much an authed-user-only problem).
|
||||
- **Location:** `crates/manager-core/src/auth_middleware.rs:192-229`.
|
||||
- **Summary:** AUDIT.md flagged this; including here because the
|
||||
DoS angle is real: a hot user with N API keys serializes every
|
||||
admin request behind N Argon2 verifies. A noisy authenticated
|
||||
tenant can stall the admin plane.
|
||||
|
||||
### L-4 — Pubsub `mint_subscriber_token` has no per-app rate limit
|
||||
- **Severity:** Low
|
||||
- **Location:** `crates/manager-core/src/pubsub_service.rs:240-…`.
|
||||
- **Summary:** Token minting requires authz, but an authenticated
|
||||
caller can mint unbounded tokens — each is a tiny HMAC, so no
|
||||
storage DoS, but it makes the topic-secret rotation surface
|
||||
noisier. Worth a sliding-window cap.
|
||||
|
||||
### L-5 — `outbox.insert` failures are not visible to the script caller in a non-200 way for fan-out paths
|
||||
- **Severity:** Low (correctness-adjacent)
|
||||
- **Location:** `outbox_event_emitter.rs:101-103` etc.
|
||||
- **Summary:** If an `INSERT INTO outbox` fails (Postgres full disk,
|
||||
deadlock), the SDK call returns the error to the script. Good. But
|
||||
the script is free to retry-in-a-loop — there's no script-side
|
||||
circuit breaker for "the outbox is unavailable". A script could
|
||||
busy-loop hitting outbox-insert failures indefinitely.
|
||||
- **Fix shape:** none beyond M-5's circuit breaker.
|
||||
|
||||
### L-6 — `inbox_router` / future SMTP ingress will need its own caps
|
||||
- **Severity:** Low / Info
|
||||
- **Location:** not yet implemented.
|
||||
- **Summary:** Heads-up for v1.2 — SMTP ingress and queue triggers
|
||||
with external receivers will need the same connection-cap + IP rate
|
||||
limit pattern as H-3.
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
|
||||
### I-1 — `pubsub::publish_durable` realtime broadcast does `tokio::spawn` + `handle.await`
|
||||
- **Location:** `crates/manager-core/src/pubsub_service.rs:222-236`.
|
||||
- **Note:** the `tokio::spawn(...)` immediately followed by
|
||||
`handle.await` defeats the comment ("Run on a child task so a
|
||||
panicking broadcaster becomes a warn log") — a panic in the spawned
|
||||
task surfaces as a `JoinError` in the awaiting frame and the publish
|
||||
succeeds (because the durable fan-out already committed). The catch
|
||||
is correct, but the comment is slightly misleading. Not a DoS finding.
|
||||
|
||||
### I-2 — `DEFAULT_TICK_INTERVAL = 100 ms` is reasonable for Pi
|
||||
- **Location:** `crates/manager-core/src/dispatcher.rs:101`.
|
||||
- **Note:** 100ms tick × 8 rows/tick = 80 dispatches/sec ceiling per
|
||||
process, before the gate. Adequate for a single-node MVP. The
|
||||
env-override (`PICLOUD_DISPATCHER_TICK_INTERVAL_MS`) lets operators
|
||||
trade latency for CPU.
|
||||
|
||||
### I-3 — Caddy in dev runs at `:80` with `auto_https off`
|
||||
- **Location:** `caddy/Caddyfile:24-26`.
|
||||
- **Note:** Prod Caddyfile enables auto-HTTPS via ACME. No HTTPS for
|
||||
the dev port is fine for development; mentioned only because every
|
||||
rate-limit story benefits from terminating TLS at Caddy.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations, prioritized
|
||||
|
||||
1. **Login rate limit + global Argon2 semaphore** (H-2). Highest
|
||||
leverage: closes the cheapest-CPU-DoS in the system.
|
||||
2. **Require HMAC on email-inbound + per-IP bucket** (H-3). One of two
|
||||
anonymous-reachable endpoints; the other is the script data plane,
|
||||
which already has a 503-gate.
|
||||
3. **Per-app caps on triggers / routes / cron, plus cron tick LIMIT**
|
||||
(H-4 + H-5). Closes the cross-tenant amplification path. Quotas
|
||||
land naturally in the v1.2 work the blueprint already plans.
|
||||
4. **Explicit `DefaultBodyLimit` at the router root** (H-1). One-line
|
||||
fix; deep value.
|
||||
5. **Caddy `request_body { max_size … }` and `servers { timeouts …}`**
|
||||
in both Caddyfiles. (Closes H-1 / M-3 at the proxy.)
|
||||
6. **Outbox GC + per-app KV/docs row caps** (M-1 / M-4). Mechanically
|
||||
easy; defends the long tail.
|
||||
|
||||
## Methodology
|
||||
|
||||
Read AUDIT.md to map prior findings and remediation state, then read:
|
||||
`crates/orchestrator-core/src/gate.rs`, `client.rs`, `api.rs`,
|
||||
`realtime.rs`, `realtime_api.rs`; `crates/manager-core/src/dispatcher.rs`,
|
||||
`cron_scheduler.rs`, `auth_api.rs`, `email_inbound_api.rs`,
|
||||
`pubsub_service.rs`, `pubsub_repo.rs`, `queue_service.rs`,
|
||||
`kv_service.rs`, `docs_service.rs`, `files_service.rs`, `http_service.rs`,
|
||||
`outbox_event_emitter.rs`, `outbox_repo.rs`, `gc.rs`,
|
||||
`triggers_api.rs`, `route_admin.rs`, `trigger_config.rs`,
|
||||
`users_service.rs` (rate-limiter shape); `crates/picloud/src/main.rs`,
|
||||
`lib.rs`; both Caddyfiles. Grepped for `DefaultBodyLimit`,
|
||||
`RequestBodyLimit`, `rate_limit`, `Semaphore`, `tracing_appender`,
|
||||
`set_keepalive`, `count_for_app`, `MAX_TRIGGERS`, `MAX_ROUTES`.
|
||||
158
security_audit/09_external_integrations.md
Normal file
158
security_audit/09_external_integrations.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# PiCloud Security Audit — External Integrations
|
||||
|
||||
**Scope:** Outbound HTTP from scripts (`http::*`), inbound email webhooks, outbound SMTP, third-party calls (SSRF / replay / auth), Postgres connection, Caddy admin, cluster RPC, `pic` CLI ↔ Manager, auto-HTTPS.
|
||||
**Tree state:** `main` at v1.1.9, commit `05ed9b0`.
|
||||
**Method:** End-to-end trace of `http::get(url)` from Rhai bridge → `HttpServiceImpl::request` → `reqwest::Client` (with `dns_resolver(SsrfResolver)`); plus targeted review of email-inbound HMAC, email-outbound SMTP, CLI TLS, and infra topology.
|
||||
|
||||
## Counts
|
||||
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| Critical | 0 |
|
||||
| High | 1 |
|
||||
| Medium | 3 |
|
||||
| Low | 3 |
|
||||
| Info | 4 |
|
||||
|
||||
The SSRF defense is unusually thorough for an MVP — full deny-list, hostname-vs-literal-IP split, resolver runs at every connect (including redirect hops), DNS-rebinding tested, cross-origin `Authorization` scrub tested. The remaining material gap is operational: **scripts can send unlimited outbound email through the operator's SMTP relay** because the only token-bucket lives in `users_service` (account-flow emails), not in `EmailServiceImpl`.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### F-EXT-H-001 — `email::send` from scripts is not rate-limited; operator's SMTP relay is a free amplifier
|
||||
|
||||
**File:** `/home/fabi/PiCloud/crates/manager-core/src/email_service.rs`
|
||||
|
||||
`EmailServiceImpl::send` (lines 257–271) checks authz, size cap, and address validity — **no rate limit, no per-app daily cap, no recipient count cap**. A token-bucket *does* exist (`/home/fabi/PiCloud/crates/manager-core/src/users_service.rs:139–216`, `EmailRateLimiter`) but it is wired only into the user-account flows (verification, password reset). Scripts calling `email::send(...)` through the SDK never traverse `users_service` — they hit `EmailServiceImpl` directly.
|
||||
|
||||
A public, anonymous HTTP-triggered script with the `AppEmailSend` capability granted to the script (the default for the `Editor` role on the app) can therefore:
|
||||
|
||||
- Send any number of mails per second (up to the global script-concurrency cap of 32).
|
||||
- Include any number of `to:` / `cc:` / `bcc:` recipients per message — `build_message` (lines 276–321) iterates the lists unbounded.
|
||||
- Reuse the operator's SMTP credentials (`PICLOUD_SMTP_*`) for spam, abuse, or relay quota exhaustion. The provider's reputation is the operator's, not the script's.
|
||||
|
||||
Combined with no per-message recipient cap, one request can fan out to thousands of `bcc:` addresses ("BCC bomb"). The SMTP relay will eventually rate-limit, but the abuse will burn through the operator's daily allowance first.
|
||||
|
||||
**Fix:**
|
||||
1. Move `EmailRateLimiter` (or a sibling instance) into `EmailServiceImpl`. Apply it inside `send` before transport handoff. Per-app daily cap **must remain per-app, not global** (agent 8's note in the scope was correct).
|
||||
2. Add a per-message recipient cap: `to.len() + cc.len() + bcc.len() <= MAX_RECIPIENTS_PER_MESSAGE` (e.g. 50, env-overridable).
|
||||
3. Surface as `EmailError::RateLimited` / `EmailError::TooManyRecipients` so scripts can handle it.
|
||||
|
||||
**Severity rationale:** This is one capability away from "the platform sends spam in the operator's name." Reputation damage + cost amplification. Bumped to High (not Critical) because `AppEmailSend` is gated on app membership for authed principals — but the moment a script is anonymous-HTTP-exposed and grants itself email, the gate evaporates (the service skips authz when `cx.principal == None`).
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### F-EXT-M-001 — Email-inbound webhook accepts unsigned POSTs when `inbound_secret` is `None`
|
||||
|
||||
**File:** `/home/fabi/PiCloud/crates/manager-core/src/email_inbound_api.rs:149–156`
|
||||
|
||||
```rust
|
||||
if let (Some(ct), Some(nonce)) = (
|
||||
target.inbound_secret_encrypted.as_ref(),
|
||||
target.inbound_secret_nonce.as_ref(),
|
||||
) {
|
||||
let secret = decrypt_secret(&s.master_key, ct, nonce)?;
|
||||
verify_signature(&headers, &body, secret.as_bytes(), &s.nonce_dedup)?;
|
||||
}
|
||||
```
|
||||
|
||||
If the trigger row has no `inbound_secret`, **signature verification is silently skipped**. Anyone who guesses or scrapes the trigger UUID can POST arbitrary email events into the outbox, which the dispatcher executes. The trigger UUID is a UUIDv7-ish secret but it leaks in admin API logs, dashboard URLs, and Mailgun/Postmark provider dashboards.
|
||||
|
||||
Agent 8 already flagged this as "the receiver accepts unsigned" — confirming here from the protocol shape. The remediation is to **make `inbound_secret` mandatory at trigger creation** (`triggers_api.rs:589`) or to refuse to mount the route unless a secret is present.
|
||||
|
||||
The replay protection (F-S-010) is correctly closed: `(ts, sha256(body))` LRU with 600 s TTL, 300 s tolerance window, constant-time `mac.verify_slice`. Signature is HMAC-SHA256 over `ts || "." || body`. All good — but only when a secret exists.
|
||||
|
||||
### F-EXT-M-002 — Inbound webhook nonce dedup is process-local; cluster mode (v1.3+) will silently degrade
|
||||
|
||||
**File:** `/home/fabi/PiCloud/crates/manager-core/src/email_inbound_api.rs:67–89`
|
||||
|
||||
`InboundNonceDedup` is a `Mutex<HashMap>` in the process address space. In single-node MVP this is fine. In cluster mode with multiple manager instances behind Caddy, a replayed POST can land on a different node and the dedup misses. The replay window is short (~300 s) so impact is limited, but the comment at line 97–99 (`"Per-process is fine for single-node MVP and dev"`) acknowledges the gap. Flag now so it isn't forgotten when cluster mode lands.
|
||||
|
||||
**Fix when cluster ships:** push the dedup tuple into a Postgres table with a `(trigger_id, ts, body_hash) PRIMARY KEY` and a 10-minute partial cleanup job, or into Redis.
|
||||
|
||||
### F-EXT-M-003 — `PICLOUD_SMTP_PASSWORD` lives in an env-derived `String` and stays in memory for process lifetime
|
||||
|
||||
**File:** `/home/fabi/PiCloud/crates/manager-core/src/email_service.rs:104, 175`
|
||||
|
||||
`SmtpConfig::password: String` is read from env and cloned into `Credentials::new(user, password)` inside `LettreEmailTransport::build`. Lettre's `Credentials` does not zero-on-drop. A heap dump (e.g. core dump shipped to a bug tracker) would expose the relay password.
|
||||
|
||||
This is acceptable for the operator-provided env-var model, but worth noting: the same secret-handling pattern as `users.password_hash` should eventually use `secrecy::SecretString` or equivalent to at least redact in `Debug`. `SmtpConfig` *does* derive `Debug` (line 87), which means `tracing::debug!` of the struct anywhere would print the password verbatim. Searched — no current call site prints it, but the foot-gun is loaded.
|
||||
|
||||
**Fix:** wrap `password` in `secrecy::SecretString` (already in tree for `MasterKey`), drop the `Debug` derive from `SmtpConfig`, or implement `Debug` manually with `[REDACTED]`.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### F-EXT-L-001 — Bounded set of opt-in HTTP options is good; `script_id` flows to outbound `User-Agent`
|
||||
|
||||
**File:** `/home/fabi/PiCloud/crates/manager-core/src/http_service.rs:371–380`
|
||||
|
||||
Default UA is `picloud/<version> (script:<script_id>)`. Operator may not want to expose script IDs to third parties (e.g. for telemetry minimization). The script_id is a UUID — not directly sensitive, but it does fingerprint the platform. Consider making the UA opaque (`picloud/<version>`) in prod or stripping the `(script:...)` segment behind a config flag.
|
||||
|
||||
### F-EXT-L-002 — `validate_url` blocks ports 22/25/465/587 but not 23/110/143/993/995/3306/6379
|
||||
|
||||
**File:** `/home/fabi/PiCloud/crates/manager-core/src/http_service.rs:343–345`
|
||||
|
||||
```rust
|
||||
if matches!(port, 22 | 25 | 465 | 587) {
|
||||
return Err(HttpError::BlockedPort(port));
|
||||
}
|
||||
```
|
||||
|
||||
The deny-list covers SSH + the canonical SMTP family. It misses telnet (23), POP3 (110/995), IMAP (143/993), MySQL (3306), Redis (6379), MongoDB (27017), memcached (11211). All of these are reachable from a script if the target's IP is public — typical for the cloud-VPC case where an internal service is on a public IP but firewalled.
|
||||
|
||||
The SSRF resolver handles the private-IP case, so the practical exploit is narrow: a public IP running e.g. an exposed Redis on 6379. Still, an allow-list (only 80/443/8080-8090/HTTPS-ALPN-ports) would be safer than the current deny-list. Mark as Low because the realistic exploit requires a misconfigured target.
|
||||
|
||||
### F-EXT-L-003 — Postgres connection: `DATABASE_URL` is required but `sslmode` is unset by default
|
||||
|
||||
**File:** `/home/fabi/PiCloud/crates/picloud/src/lib.rs:607–618`
|
||||
|
||||
`PgPoolOptions::new().connect(url)` honors whatever `sslmode=` is in `DATABASE_URL`. Default for sqlx/Postgres is `prefer` — it tries TLS, falls back to plaintext silently. In the dev docker-compose Postgres is bound to a private network, so plaintext is fine — but in a real prod deploy where Postgres lives on a managed service, an operator who forgets `?sslmode=require` may transmit credentials cleartext on the LAN.
|
||||
|
||||
**Fix (doc, not code):** the production docker-compose / deploy guide should call this out. Optionally: parse `DATABASE_URL` at startup and warn if `sslmode` is missing or `disable`/`prefer` when host is not `localhost`/`postgres`/`127.0.0.1`.
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
|
||||
### F-EXT-I-001 — SSRF defense is solid: confirmed end-to-end trace
|
||||
|
||||
Traced `http::get("http://169.254.169.254/")` from Rhai:
|
||||
|
||||
1. `sdk/http.rs:78` → `invoke(...)` builds `HttpRequest { url: "http://169.254.169.254/", ... }`.
|
||||
2. `block_on` (line 383) → `HttpServiceImpl::request` (`http_service.rs:166`).
|
||||
3. → `run` (line 210) → `validate_url` (line 217) parses host as `Host::Ipv4`, calls `policy.check(IpAddr::V4(169.254.169.254))`.
|
||||
4. `ssrf.rs:check_v4` line 77: `[169, 254, ..] => Err("link-local")`.
|
||||
5. Returned to script as `HttpError::Ssrf("link-local")`.
|
||||
|
||||
For hostnames (e.g. `metadata.google.internal`), `validate_url` passes through (it's a `Host::Domain`), the resolver in `ssrf.rs:181-220` does the lookup, sees the resolved IP is 169.254.x.x, filters it out, and if all addresses are denied, returns the SSRF marker error which `map_reqwest_err` (line 401) → `ssrf_reason` (line 419) walks the chain for. **Tested at `http_service.rs:732, 750` with both literal-IP and hostname-via-`localhost` paths.**
|
||||
|
||||
Redirect hops re-resolve through the same DNS resolver (because `redirect(Policy::none())` + manual follow at line 225–280 re-creates each request through the same client), so redirect-to-SSRF is blocked. Cross-origin `Authorization` scrub is implemented at lines 261–265 and tested at lines 868–892. DNS-rebinding is tested at `ssrf.rs:411–443`.
|
||||
|
||||
### F-EXT-I-002 — Cluster-mode RPC: no inter-service auth today
|
||||
|
||||
The three split binaries (`picloud-manager`, `picloud-orchestrator`, `picloud-executor`) are skeletons. The `ExecutorClient` trait abstraction is in place for the swap to remote HTTP, but cluster mode itself is unbuilt. Authentication between services is a v1.3+ design problem; nothing to flag at this stage beyond "don't ship cluster without it."
|
||||
|
||||
### F-EXT-I-003 — Caddy admin: dev has `admin off`, prod uses Caddy's localhost-only default
|
||||
|
||||
**Files:** `caddy/Caddyfile:20`, `caddy/Caddyfile.prod` (no `admin` block).
|
||||
|
||||
Dev caddyfile sets `admin off` — closed. Prod inherits Caddy's default `admin localhost:2019`. As long as the container's port 2019 is not published in `docker-compose.prod.yml` (verified: only the front-facing 80/443 are exposed), the admin API is unreachable from the LAN. Good. Worth adding `admin off` to the prod caddyfile too, for defense in depth.
|
||||
|
||||
### F-EXT-I-004 — `pic` CLI uses default rustls TLS; no `--insecure` flag exists
|
||||
|
||||
**File:** `/home/fabi/PiCloud/crates/picloud-cli/src/client.rs:33–43`
|
||||
|
||||
`reqwest::Client::builder()` with no `danger_accept_invalid_certs` call. Bearer token transmitted in `Authorization` header (line 53), never URL. Audit-clean.
|
||||
|
||||
---
|
||||
|
||||
## What was NOT found / out of scope
|
||||
|
||||
- **HTTP-async (`http_async::request`)**: the scope brief mentioned this surface, but a workspace-wide search for `http_async` / `HttpAsync` returned zero hits — the dispatcher (`dispatcher.rs`) does not invoke any HTTP client and there's no `http_async` SDK module. Either the feature is deferred (post-v1.1.9) or already collapsed into the same `http_service` path — both are fine.
|
||||
- **Bounce handling**: no bounce-processing code exists. Nothing to leak.
|
||||
- **SMTP injection via CRLF**: agent 4's scope. Not re-audited here.
|
||||
114
security_audit/10_info_disclosure_cli.md
Normal file
114
security_audit/10_info_disclosure_cli.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 10 — Information disclosure, logging, CLI/operator token handling
|
||||
|
||||
**Scope:** Error-response shaping, request/audit logging, CLI (`pic`) token handling, dashboard token storage, build/observability surface.
|
||||
**Reviewed at:** `main` (matches AUDIT.md head, v1.1.9).
|
||||
|
||||
## Counts
|
||||
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| High | 2 |
|
||||
| Medium | 4 |
|
||||
| Low | 4 |
|
||||
| Info | 5 |
|
||||
|
||||
## Trace samples actually observed
|
||||
|
||||
- **Error path (unique-violation on admin create):**
|
||||
`manager-core/src/admin_users_api.rs:464-467` maps
|
||||
`AdminUserRepositoryError::DuplicateUsername(_) | DuplicateEmail(_)`
|
||||
to a 409 whose body is `Json({"error": self.to_string()})`. The thrown error string is the typed enum variant ("username already taken"), **not** the raw `sqlx`/Postgres message; the constraint name and column never reach the client. Generic `Db(e)` and `AuthzRepo(e)` paths sanitize to `"internal error"` while logging the underlying error server-side. Same shape repeats across every `*_api.rs` (`api.rs`, `apps_api.rs`, `secrets_api.rs`, `dead_letters_api.rs`, `topics_api.rs`, …). DB-error leakage is **not** present.
|
||||
|
||||
- **Happy-path log emission (login):**
|
||||
`manager-core/src/auth_api.rs:75-110`. The handler does the Argon2 verify, **never** logs `input.username` / `input.password`. Error paths use `tracing::error!(?err, "admin_users credentials lookup failed")` etc. — the field captured is the repo error, not the credentials. The cookie/Bearer token is hashed (`hash_token`) before any field appears in `auth_middleware.rs:204-208`; only `token_hash` ever lands in `tracing::error!`. `tower_http::trace::TraceLayer::new_for_http()` at `crates/picloud/src/lib.rs:593` emits the URI but headers (including `Authorization`) are off by default for that layer.
|
||||
|
||||
- **CLI token write (`pic login`):**
|
||||
`crates/picloud-cli/src/config.rs:92-109` `write_private` opens the credentials file with `OpenOptions::new().mode(0o600)` and then re-`set_mode(0o600)` as belt-and-suspenders. Unit test `posix_mode_is_0600` (line 137) asserts the file mode at rest. The token is persisted as TOML (`token = "pic_..."`) in plaintext — no symmetric encryption with a passphrase or OS keychain integration, so a stolen home dir reveals the token. Not surprising for a developer-targeted CLI, but worth flagging as Info.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H1 — Dashboard bearer token stored in `localStorage` (XSS-readable)
|
||||
- **Location:** `dashboard/src/lib/auth.ts:22-48` (`TOKEN_KEY = 'picloud.admin.token'`, `localStorage.getItem/setItem`).
|
||||
- **Detail:** The same string used as the `Authorization: Bearer` value AND as the session cookie value lives in `localStorage`. Any XSS anywhere in the SPA (CodeMirror script editor, an in-dashboard log viewer that renders user-controlled JSON, a third-party dep with a prototype-pollution sink, etc.) reads the token in one line of attacker JS and exfiltrates it — the token is bearer-only with no per-request signing, so theft = full account takeover for the saved TTL (default 24 h sliding). The cookie path is described in the file's comments as the "primary" channel ("the bearer token doubles as the cookie value on the server side") but no `HttpOnly` / `Secure` / `SameSite` cookie is actually set by the dashboard — `setSession()` only writes to the store, then to `localStorage`. The middleware accepts the cookie if Caddy/browser sets one, but the dashboard itself does not.
|
||||
- **Fix shape:**
|
||||
- Move the token to memory only (Svelte store, no `localStorage` echo) + a sibling `HttpOnly; Secure; SameSite=Strict` cookie set by `/auth/login` server-side. `auth_api.rs:125-145` already returns the token; have it also `Set-Cookie` and have `auth_middleware.rs:373-383` already reads the cookie.
|
||||
- Or: keep `localStorage` but pin to a **refresh token** with a 60-300s sliding access token kept in memory.
|
||||
- Adding a CSP `default-src 'self'; script-src 'self'` for the dashboard ships materially mitigates the blast radius but does not eliminate it.
|
||||
|
||||
### H2 — `pic admins create/set --password VALUE` accepts the password on argv
|
||||
- **Location:** `crates/picloud-cli/src/main.rs:558` (Create), `:576` (Set); `crates/picloud-cli/src/cmds/admins.rs:50-58`.
|
||||
- **Detail:** Both subcommands take `--password <PASSWORD>` as a plain `Option<String>`. The clap arg is **not** restricted to `-` (stdin). When operators do the natural thing (`pic admins create alice --password hunter2`), the plaintext password ends up in:
|
||||
- the shell history (`~/.bash_history`, `~/.zsh_history`)
|
||||
- `ps aux` for the lifetime of the process
|
||||
- `/proc/<pid>/cmdline` for any other UID on the host while it runs
|
||||
- `pic login --token <T>` (`main.rs:126`) has the same flaw for an API key. The help text warns ("Passing the password inline puts it in shell history — pipe instead when scripting") but the warning is documentation-only.
|
||||
- **Fix shape:**
|
||||
- Reject any `--password` value other than `-` outright (return an error with "use `--password -` to read from stdin"), or
|
||||
- Accept the inline form but actively zero out `argv[N]` after parsing (`prctl(PR_SET_NAME)` on Linux + memset of the underlying `String`, both of which require unsafe / nightly tricks for `String`), or
|
||||
- Drop the inline form entirely and require `--password -`.
|
||||
- Same fix for `pic login --token VALUE` — make it `--token-stdin` or just remove the flag (the env-var fallback already covers CI).
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M1 — Distinguishable 404s for "unknown host" vs "no route on host" enable enumeration
|
||||
- **Location:** `crates/orchestrator-core/src/api.rs:188-208`.
|
||||
- **Detail:** Two distinct JSON bodies:
|
||||
- `"no app claims host {host:?}"` when the `Host` header doesn't match any app's domain claim.
|
||||
- `"no route matches {method} {path}"` when the host is claimed but no route fires.
|
||||
An unauthenticated attacker can enumerate the set of apps installed by walking common subdomains (`api.`, `app.`, `admin.`, …) and watching which 404 they receive, even though all such hosts must resolve to the operator's IP. Combined with the `/version` `public_base_url` it also reveals the canonical host the operator picked.
|
||||
- **Fix shape:** Collapse both to a single uniform `"no route matches METHOD PATH"` (the host case is the same kind of routing miss from the caller's perspective). Log the distinction server-side for debugging; don't expose it to the client.
|
||||
|
||||
### M2 — `pic admins` "no echo when interactive" comment is misleading; stdin read does echo
|
||||
- **Location:** `crates/picloud-cli/src/cmds/admins.rs:60-83`.
|
||||
- **Detail:** `read_password_from_stdin` says
|
||||
> "Prompt without echoing — rpassword would be cleaner, but the CLI already avoids extra crates"
|
||||
but the implementation calls `stdin.lock().read_line(&mut buf)` — which **does** echo characters to the terminal. `crates/picloud-cli/src/cmds/login.rs:64` uses `rpassword::prompt_password` correctly. The mismatch means `pic admins create alice --password -` typed at a TTY echoes the password on screen. (`rpassword` is already a transitive dep via `pic login`, so the "avoid extra crates" rationale is moot.)
|
||||
- **Fix shape:** Use `rpassword::prompt_password` in the same place. Same applies to `crates/picloud/src/main.rs:203` (`prompt_password_from_stdin`) — also `read_line`-based with an "(will be read from stdin, no echo)" prompt that's a lie.
|
||||
|
||||
### M3 — `ExecError::Runtime` / `ExecError::Parse` strings echoed verbatim into responses
|
||||
- **Location:** `crates/orchestrator-core/src/api.rs:745-753`; `executor-core/src/types.rs:138-160`.
|
||||
- **Detail:** Rhai parse and runtime error strings are passed straight into the HTTP error body (`e.to_string()` → JSON). Rhai's error messages include line/column positions and (for runtime errors) snippets of the script source. For a public unauthenticated route to a script the caller doesn't own, this reveals script internals to the world. The blast radius is limited (Rhai source is uploaded by the script owner so it's "their" data leaking back), but for multi-tenant setups where one app's script is invoked across apps via `invoke()` it's a cross-tenant leak.
|
||||
- **Fix shape:** Two-tier — log full message server-side, return only a stable kind ("script error", "script parse error") plus a correlation id to the client. Keep the verbose form for `/api/v1/admin/scripts/{id}/test` (authenticated, intra-app).
|
||||
|
||||
### M4 — Credentials file is plaintext TOML with no at-rest encryption
|
||||
- **Location:** `crates/picloud-cli/src/config.rs:81-89`.
|
||||
- **Detail:** Token sits as `token = "pic_..."` in `$XDG_CONFIG_HOME/picloud/credentials`. Mode 0600 protects against same-host other-UID reads, but does not protect against backup capture, full-disk forensics of a stolen laptop, or a cloud-sync daemon that ignores the mode and uploads it. Several comparable CLIs (`gh`, `gcloud`) ship the same plaintext shape, so this is best-practice in line with peers — but worth being explicit about.
|
||||
- **Fix shape:** Optional: add `pic login --keyring` that stores via OS keychain (Secret Service / Keychain / Credential Manager via the `keyring` crate) and falls back to plaintext when no keychain is reachable. Not required for v1.x.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L1 — `prompt_password_from_stdin` in `picloud admin reset-password` echoes the password
|
||||
- **Location:** `crates/picloud/src/main.rs:203-216`. Prompt advertises "will be read from stdin, no echo" but uses `read_line`. Same fix as M2.
|
||||
|
||||
### L2 — `Authorization: Bearer pic_…` accidentally placed in a URL query would land in tower-http TraceLayer logs
|
||||
- **Location:** `crates/picloud/src/lib.rs:593` `.layer(TraceLayer::new_for_http())`.
|
||||
- **Detail:** `TraceLayer` logs full URI including query string at `DEBUG`/`INFO`. The platform doesn't accept tokens via query string today, but the trace layer would log any future `?token=` or `?api_key=` parameter. Belt-and-suspenders: add a `MakeSpan` that strips known sensitive query keys, OR explicitly document "tokens go in headers, not in queries" in the SDK.
|
||||
|
||||
### L3 — `eprintln!` for env-var validation failure logs env-var value
|
||||
- **Location:** `crates/manager-core/src/secrets_service.rs:59-62`.
|
||||
- **Detail:** `tracing::warn!(value = %v, "ignoring invalid PICLOUD_SECRET_MAX_VALUE_BYTES …")` echoes the operator-supplied value. The env var holds an integer in normal operation, so this is harmless, but the same pattern is copy-pasted across the file caps. If an operator typos `PICLOUD_FILES_ROOT` as a path containing a secret (e.g. an embedded credential in a network path), it would land in logs. Tiny.
|
||||
|
||||
### L4 — `PICLOUD_HTTP_ALLOW_PRIVATE` warning shouts loudly but does not name the listener (good)
|
||||
- **Location:** `crates/picloud/src/lib.rs:172-176`. Info — included for completeness; the warning is the correct shape.
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
|
||||
- **I1 — `/healthz` is literally `"ok"`** (`crates/picloud/src/lib.rs:621`). No leak.
|
||||
- **I2 — `/version` returns only `product` / `sdk` / `api` / `schema` / `wire` / `public_base_url`** (`crates/picloud/src/lib.rs:632-642`). No build SHA, no OS, no internal IP. Good.
|
||||
- **I3 — No custom `Server` header set anywhere.** Hyper's default `Server: hyper` may leak via the framework, but no manual augmentation. Info.
|
||||
- **I4 — No dedicated admin-action audit log table.** Grep for `audit_log` finds only `execution_logs` (script outcomes) and DL rows. Admin actions like "user X minted API key Y", "user X deleted app Z", "user X promoted user Y to owner" are not durably recorded. Out of scope for this audit (overlaps with agent 1/2) but flagging for the operator-trust story.
|
||||
- **I5 — Source maps not emitted in prod.** `dashboard/svelte.config.js` uses `adapter-static`; Vite default `build.sourcemap = false`. No leak.
|
||||
|
||||
---
|
||||
|
||||
## Most important finding (2-3 sentences)
|
||||
|
||||
H1 (dashboard token in `localStorage`) is the biggest single risk: a bearer token good for full admin takeover sits in DOM-readable storage with no XSS mitigation in front of it, and a CSP is not configured. Combined with the CodeMirror-based script editor (which evaluates user-typed Rhai) and a dashboard that renders trigger payloads + execution-log fields from JSONB columns, any one HTML-injection bug becomes a full account takeover. The fix is well-scoped — switch the server-side login response to also set an `HttpOnly; Secure; SameSite=Strict` cookie, keep an in-memory access token for SPA reloads, and drop the `localStorage` echo entirely.
|
||||
109
security_audit/REVIEW.md
Normal file
109
security_audit/REVIEW.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Review — Security remediation `fix/audit-2026-06-11` (Tier 1+2)
|
||||
|
||||
Reviewer: independent audit pass (not the implementing agent).
|
||||
Date: 2026-06-12.
|
||||
Branch: `fix/audit-2026-06-11` (11 commits off `main`, unpushed).
|
||||
Scope reviewed: the 2 Criticals + 8 Highs claimed closed in the handback.
|
||||
|
||||
## Verdict: **APPROVE** — ready to ff-merge into `main`.
|
||||
|
||||
All 10 Tier-1+2 findings are correctly implemented, the security logic is sound, and the
|
||||
production test suites are green. The single failing test is pre-existing and out of scope
|
||||
(disclosed honestly in the handback). The findings below are **non-blocking** follow-ups.
|
||||
|
||||
---
|
||||
|
||||
## What I verified (independently, not from the handback)
|
||||
|
||||
### Build gates
|
||||
- `cargo clippy --all-targets --all-features -- -D warnings` → clean (exit 0).
|
||||
- `cargo fmt --all --check` → clean.
|
||||
|
||||
### Tests (run against throwaway Postgres 16 containers)
|
||||
| Suite | Result | Notes |
|
||||
|---|---|---|
|
||||
| `picloud --test api` | **53 passed** | in-process server boot |
|
||||
| `picloud --test authz` | **28 passed, 1 failed** | the 1 failure is pre-existing — see below |
|
||||
| `picloud --test email_inbound` | **8 passed** | directly exercises H-B2 |
|
||||
| `manager-core --test schema_snapshot` | **1 passed** | migration 0042 applies; hand-edited `expected_schema.txt` matches the real migrated DB exactly |
|
||||
| `picloud-cli --test cli` | **75 passed** (fresh DB) | see "test-harness artifacts" |
|
||||
| unit tests (manager-core 343, executor-core 74, shared, orchestrator, …) | **all passed** | |
|
||||
| executor-core doctests | ignored doctest correctly skipped under normal `cargo test` | |
|
||||
|
||||
Net: every suite the handback claimed green **is** green when run correctly.
|
||||
|
||||
### Per-finding code verification
|
||||
- **C-1 (cookie CSRF)** — `extract_token` cookie path fully removed; `SESSION_COOKIE` const deleted; login response no longer emits `Set-Cookie` (`build_cookie` + `SET_COOKIE` gone). Tests now assert cookie rejection. Dashboard already uses Bearer. ✅
|
||||
- **C-2 (stored XSS)** — download forces `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` + `default-src 'none'; sandbox; frame-ancestors 'none'` CSP + `Referrer-Policy: no-referrer`; content-type re-sanitized through an allowlist at serve time; write path coerces dangerous types at `create`/`update`. Allowlist correctly coerces `text/html`, `image/svg+xml`, `application/javascript`, `application/xhtml+xml`, flash, xml. ✅ (one residual edge below)
|
||||
- **H-A1/A2/A3 (headers)** — Caddy `header` layer adds CSP/X-Frame-Options→`frame-ancestors`/Permissions-Policy/`no-store`/HSTS; `?`-operator lets the C-2 per-download CSP win on file routes. ✅
|
||||
- **H-B1 (login DoS)** — per-`(ip, username)` + per-`username` bucket gates **before** acquiring a bounded Argon2 semaphore (correct ordering — attackers can't queue), fails closed on a closed semaphore. ✅
|
||||
- **H-B2 (email webhook)** — bad-signature limiter checked **before** verification; no-secret path fails closed (401) as defense-in-depth; new triggers require a secret; email-trigger secret correctly stays on the `open_legacy` (v0, no-AAD) path so the AAD migration didn't break it. ✅
|
||||
- **H-C1 (runaway script)** — thread-local `DeadlineGuard` consulted by `engine.on_progress`, returning `Some(Dynamic::UNIT)` to halt eval; both `spawn_blocking` sites pass `Some(deadline)`; invoke re-entries inherit the deadline; default `None` preserves test/validation behavior. ✅
|
||||
- **H-D1 (AES-GCM AAD)** — `encrypt_with_aad`/`decrypt_with_aad` added with round-trip + AAD-mismatch tests; `secrets`/`app_secrets` seal/open bind `app_id`(+`name`) AAD; read path **dispatches on the `version` column** (v0 → no-AAD legacy decrypt, v1 → AAD, else `Corrupted`); migration 0042 adds `version SMALLINT NOT NULL DEFAULT 0` so existing rows keep decrypting. The dev correctly pinned the subtle RustCrypto fact that v0 == empty-AAD-v1, so version-column dispatch (not ABI) is the discriminator. ✅
|
||||
- **H-E1 (password change)** — now `delete_for_user` + `expire_all_for_user` on password change, mirroring the deactivation path. ✅ (best-effort caveat below)
|
||||
- **H-F1 (dispatcher cross-app)** — queue arm dead-letters on `script.app_id != claimed.app_id` (observable, doesn't execute); HTTP arm returns `ResolveTrigger` error. Mirrors `build_invoke_request`. ✅
|
||||
- **H-H1 (email::send)** — `send()` enforces a per-message recipient cap (`TooManyRecipients`) + per-`(app_id, recipient)` rate limiter (`RateLimited`). Per-app, as the audit asked. ✅
|
||||
|
||||
### The one failing test is genuinely pre-existing
|
||||
`picloud --test authz deactivating_user_revokes_their_api_keys` asserts 401 but gets 200
|
||||
(`authz.rs:651`). This is the **PrincipalCache revocation-lag** Medium the audit itself flagged
|
||||
as deferred (`expire_all_for_user` flips the DB row, but the in-process cache serves the stale
|
||||
principal until TTL). Confirmed pre-existing via git: the branch leaves **both** `authz.rs`
|
||||
(the test) **and** the deactivation revocation path byte-identical to `main` — H-E1 only added
|
||||
invalidation to the *password-change* path, not deactivation. The handback's disclosure is
|
||||
accurate.
|
||||
|
||||
### Test-harness artifacts in my combined run (not code defects)
|
||||
When I ran `cargo test --workspace --no-fail-fast -- --include-ignored` against a single shared
|
||||
DB, I saw 77 "failures." All but the one above are harness artifacts:
|
||||
- **75 × picloud-cli** — the fixture spawns the `picloud` binary as a subprocess; bootstrap only
|
||||
seeds the fixture admin when `admin_users` is empty, but the earlier suites had already
|
||||
populated that table in the shared DB → fixture login 401 → poisoned `LazyLock` cascade.
|
||||
Against a **fresh** DB, all 75 pass. `server.rs` is unchanged by the branch.
|
||||
- **1 × engine doctest** (`set_self_weak`, line 172) — a ` ```ignore ` pseudo-code example.
|
||||
`--include-ignored` forced it to compile; under a normal `cargo test` it is correctly skipped.
|
||||
Unchanged by the branch.
|
||||
|
||||
---
|
||||
|
||||
## Non-blocking findings (recommend as follow-ups, not merge blockers)
|
||||
|
||||
1. **C-2 sanitizer lets CRLF through an allowlisted prefix.**
|
||||
`sanitize_stored_content_type("image/png\r\nX-Injected: yes")` matches the `image/` prefix
|
||||
and returns the **original** string (CRLF intact). `NewFile::validate` is length-only, so such
|
||||
a value can be stored; on download it would reach `.header(CONTENT_TYPE, …)` → `.expect(...)`
|
||||
and panic the task (Axum catches it per-task → 500, file's download poisoned). This is the
|
||||
pre-existing agent-4 Medium — **not introduced** by C-2, and C-2 narrows the surface — but the
|
||||
function named `sanitize_*` gives false confidence. One-line fix: reject/strip bytes
|
||||
`< 0x20`/`0x7f` in `sanitize_stored_content_type` (or in `validate`).
|
||||
|
||||
2. **H-E1 invalidation is best-effort.** `delete_for_user` / `expire_all_for_user` errors are
|
||||
logged but don't fail the password-change response. If `delete_for_user` errors, the password
|
||||
rotates while sessions survive — the exact gap being closed. Consider surfacing the failure
|
||||
(or retrying) rather than only `tracing::error!`. Same shape exists on the pre-existing
|
||||
deactivation path, so it's a consistency point, not a regression.
|
||||
|
||||
3. **H-B2 lockout is trigger-global.** An attacker flooding bad signatures locks out *legitimate*
|
||||
inbound email to that trigger for the 60s window. Inherent to lockout-style limiters and
|
||||
acceptable for single-node MVP; worth a comment so it's a known trade-off.
|
||||
|
||||
4. **Operational breaking change (intended).** Existing email triggers with a null `inbound_secret`
|
||||
now return 401 (the H-B2 fix). Operators must add a secret to any pre-audit secret-less email
|
||||
trigger. Call this out in release notes.
|
||||
|
||||
5. **Cosmetic.** `bearer_and_cookie_produce_same_principal` no longer tests cookies (its body uses
|
||||
`Authorization: Bearer`). Rename to avoid implying cookie auth still exists.
|
||||
|
||||
6. **Confirm `remote_ip` source for H-B1.** Behind Caddy the socket peer is Caddy's IP (so the
|
||||
per-IP bucket degrades to global; the per-username bucket still protects). If it instead trusts
|
||||
an unvalidated `X-Forwarded-For`, that's spoofable. Worth a one-line check of how `remote_ip`
|
||||
is derived. Not a blocker either way.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
ff-merge `fix/audit-2026-06-11` into `main`. The Tier-1+2 security work is correct, tested, and
|
||||
honestly documented. File the six items above as Tier-3 follow-ups alongside the already-deferred
|
||||
work (per-app quotas, body limits, `disable_symbol("debug")`, anon error-scrub, email-trigger AAD
|
||||
upgrade, PrincipalCache eviction).
|
||||
Reference in New Issue
Block a user