Files
PiCloud/examples/cms-poc/SECURITY.md
MechaCat02 813bc46640
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 39m53s
CI / Dashboard — check (push) Successful in 10m15s
docs(examples): add the CMS proof-of-concept dogfooding artifact
A WordPress-inspired CMS whose entire backend is PiCloud Rhai scripts
deployed with pic apply, plus a SvelteKit frontend. Built to dogfood the
project tool, CLI, and SDK; exercises docs/kv/files/users, docs+queue+cron
+pubsub triggers, the transactional-outbox notification chain, a docs
before-interceptor, set_if CAS, SSE, per-app CORS, env overlays, and a
durable Workflow (validate -> enrich || seo -> publish -> finalize) started
with workflow::start and polled with workflow::run_status, visualized live
with Svelte Flow.

FINDINGS.md / SECURITY.md capture every gap, surprise, and security issue
found (each tagged [PiCloud] vs [CMS]); the platform fixes for the
actionable ones ship in the preceding commit.

Also folds in the read-only `pic` allowlist entries added to
.claude/settings.json during the session (fewer-permission-prompts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:15:29 +02:00

9.5 KiB

PiCloud CMS PoC — Security Audit

Security findings from building & probing the CMS. Each is tagged [PiCloud] (the weakness originates in the platform / SDK) or [CMS] (our application code, i.e. a mistake the platform lets you make). Cross-referenced with FINDINGS.md.

Severity: 🔴 critical · 🟠 high · 🟡 medium · 🔵 low/info


UPDATE 2026-07-18 (fix/cms-poc-findings): S-01/S-12 FIXED and re-verified. The user-route path now runs errors through the shared scrub_runtime_detail helper: the client receives only {"error":"script execution error (ref: <uuid>)"} (HTTP 502) and the full detail is logged server-side under the same correlation id. Live-verified with a throw carrying a secret marker — no marker, app-id, function name, or source line reached the client. The highest-impact platform issue in this audit is closed. (S-02, the no per-row authz design point, remains by design — but its blast-radius amplifier is gone.)

S-01 🟠🟢 [PiCloud] Uncaught script errors return a 502 that leaks script internals — FIXED

Finding: Any uncaught Rhai error — including an intentional throw, but also an unexpected SDK error (KV/docs failure, a users::* throw, a type error) — is returned to the HTTP client as a 502 whose JSON body embeds: the app UUID (app:2cced4ba-e20b-44c9-9080-d698cc2d4cca), the function names in the call stack (require_user), and line/column positions of the script source.

Impact: Information disclosure. An attacker who can trigger an error on any public route (malformed input, oversized payload, a value that makes a script throw) harvests the app's internal id and a map of its script structure. The app id is a capability-shaped identifier used across the admin API.

Attribution: Platform. A script author cannot change the 502 shape; the only defense is to try/catch every fallible SDK call and every code path — impractical and easy to miss. The platform should return an opaque 500/502 and log the detail server-side, not ship the stack to the client.

CMS mitigation: the auth::guard() pattern never throws across the endpoint boundary, and endpoints wrap users::create/parse steps in try/catch. This is defense the app must remember to apply everywhere; coverage is only as good as author discipline.


S-02 🔵 [PiCloud] Public route = full app authority (documented footgun)

Finding: A script bound to a public route runs with principal: None yet holds full app authority — it can read/write every KV/docs/files collection and read every secret. There is no platform-level per-row or per-collection authorization; authz::script_gate returns Ok for an anonymous caller. The script body is the only access boundary.

Impact: Every authorization decision in the CMS (is this user an admin? may they edit this post? may they read this draft?) is app code the author must get right. A single missing check exposes the whole app's data. See S-03 (IDOR) and the S3/S4 audit rows.

Attribution: Platform design (documented in docs/sdk-shape.md). Noted here because it is the root cause that makes S-01/S-03-class bugs so high-impact: there is no backstop.

CMS mitigation: the auth module is imported by every privileged endpoint; public endpoints are enumerated and each scoped to only published/approved rows (verified in later stages).

(Further rows added as later stages are probed: comment XSS, IDOR on docs ids, password-reset tokens, enumeration/rate-limits, email header injection, SSRF, secret exposure, SSE topic authz.)


S-03 🟢 [CMS] IDOR / RBAC enforced in-script — verified

Finding (positive, with caveat): Because the platform provides no per-row authz (S-02), the CMS enforces it in code. Probed:

  • author editing another author's post → 403 (post_update checks cur.author_id == user.id unless admin);
  • reader creating a post → 403 (auth::guard(["admin","author"]));
  • anonymous hitting an admin route → 401;
  • public GET /cms/posts/:slug on a draft → 404 (status re-checked after slug→id resolve, so guessing a draft's slug reveals nothing).

Caveat / attribution: every one of these is app code that could have been omitted with no platform warning. The doc id is a UUID (not enumerable), which helps, but the security of the whole content model rests on each endpoint importing auth and re-checking ownership. This is the S-02 footgun realized: correct here, but only by discipline. A missing guard() on any single admin route would be a silent full bypass.


S-04 🟢 [CMS+PiCloud] Stored-XSS defense via interceptor — verified, with a rendering contract

Finding: Comment bodies + author names are HTML-escaped at write time by the comment_sanitize docs interceptor (see FINDINGS F-018). Probed <script>, <img src=x onerror>, &, <b> — all persisted escaped; status forced to pending under manual moderation so nothing shows publicly until an admin approves.

Caveats / attribution:

  • [PiCloud] create-only coverage — the hook is ops=["create"]; a docs::update to a comment body would NOT be re-escaped (F-020). Defense depends on the author remembering to hook update too.
  • [CMS] rendering contract — because bodies are stored escaped, the frontend must treat them as pre-escaped (render the escaped string as text, never re-inject raw). Storing escaped + rendering raw-innerHTML would be safe; storing escaped + double-escaping is only a display bug; the dangerous combo (store raw + innerHTML) is avoided by escaping at the boundary.
  • [PiCloud] fail-open risk — if the sanitizer is mis-scoped or its ops list misses a path, the platform gives no warning that a write reached the store unsanitized. The interceptor's own fail-closed verdict only protects the ops it's actually registered for.

S-05 🔵 [CMS] Commenter emails withheld from public API — verified

comments_for_post strips author_email before returning; only approved comments are listed. Admin-only comments_admin retains the email for moderation. No email harvesting via the public comment feed.


S-06 🟢 [PiCloud] Email header injection is prevented by the SDK

Published a post whose title contained Injected\r\nBcc: attacker@evil.test\r\nX-Evil: 1; that title flows into the notification email::send_html subject. The delivered message encoded the subject as an RFC 2047 word (Subject: =?utf-8?b?...?=), so the CRLF is inert — no injected Bcc recipient, no X-Evil header (verified against the raw message in Mailpit). email::send/lettre encodes header values, so CRLF-in-header-value injection is not possible. Attribution: platform (good).

S-07 🟢 [PiCloud] http:: blocks SSRF to private/loopback by default

The outbound http:: service wires an SsrfResolver with allow_private = false by default (http_service.rs:70, SsrfPolicy), filtering private/loopback/link-local addresses at DNS resolution; only an admin opt-in flips it. This CMS doesn't fetch user-supplied URLs, so SSRF isn't reachable here — but had we (e.g. remote-image import), the platform defends by default. Attribution: platform (good).

S-08 🟢 [PiCloud] Secrets never returned in plaintext

pic config --effective shows setup-token as <set> (status managed), never the value; no endpoint echoes secrets::get, and the bootstrap endpoint compares server-side without reflecting the token. AES-GCM at rest. Good.

S-09 🟡 [CMS] User-existence oracle via registration + email_available

POST /cms/auth/register returns 409 "email already registered" for a taken email vs 201 for a new one — a reliable existence oracle. users::email_available is documented as the anon-safe probe (and is the honest way to check), but it's not rate-limited by the platform (only our per-IP kv limiter gates it, and that limiter shares one bucket when x-forwarded-for is absent / is spoofable). Attribution: mostly [CMS] (we chose to surface 409), but [PiCloud] contributes: there's no platform throttle on users::*, no captcha primitive, and no trustworthy client-IP in ctx to key a limiter on. A production CMS would return a generic "check your email" and add real rate limiting at the proxy.

S-10 🟡 [PiCloud] Session token lives in localStorage (XSS → token theft)

users::login returns a bearer token the SPA stores in localStorage (there's no httpOnly-cookie session option from users::). Any XSS in the app can exfiltrate it. The comment interceptor (S-04) is the main stored-XSS guard, but the token model puts a premium on that guard never failing. Attribution [PiCloud]: the app-user auth model is bearer-token only; it offers no cookie/CSRF-style option, so browser apps must hold the token in JS-reachable storage. (Upside: no cookies → no CSRF surface.)

S-11 🔵 [CMS] Public media is world-readable by id; no per-object ACL

GET /cms/media/:id returns any file in the media collection to anyone. Fine for a blog (media is public) and ids are UUIDs, but there's no platform per-object authz (S-02), so a "private draft image" feature would need app-enforced checks. Noted, not exploitable here.

S-12 🟠 [PiCloud] The 502 info-leak (S-01) is the highest-impact platform issue

Re-flagging: because uncaught errors leak the app UUID + script structure (S-01), and because all authz is app-code (S-02), the blast radius of any un-try-caught path is amplified. This pairing — no per-row authz + verbose error disclosure — is the single most important thing a PiCloud app author must design around.