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>
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 sharedscrub_runtime_detailhelper: 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 athrowcarrying 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_updatecheckscur.author_id == user.idunless admin); - reader creating a post → 403 (
auth::guard(["admin","author"])); - anonymous hitting an admin route → 401;
- public
GET /cms/posts/:slugon 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"]; adocs::updateto 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
opslist 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.