# 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` 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/ (script:)`. 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/`) 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.