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>
This commit is contained in:
139
examples/cms-poc/SECURITY.md
Normal file
139
examples/cms-poc/SECURITY.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 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](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.
|
||||
Reference in New Issue
Block a user