01-projects/printables-product/audit-2026-09-02

Scribble Works — Security & Abuse Audit (Track B)

2026-09-02·status: complete

Overall posture: structurally sound on the two things that matter most (sign-in and model-output-to-DOM), with one genuinely open door in the feedback Worker and a cluster of fail-open defaults and privacy-copy drift that should be closed before any real traffic.

Counts: 0 Critical · 1 High · 7 Medium · 9 Low.


HIGH

H1 — Twilio webhook fails open; /sms is an unauthenticated write + SSRF door

ASVS V4 (access control), V12 (SSRF) workers/feedback-intake/src/twilio.js:51 — if (!token) return { ok: true, checked: false }; workers/feedback-intake/src/index.js:95-98, :69-90; workers/feedback-intake/wrangler.toml (workers_dev = true, TWILIO_TOKEN listed as an unset secret, comment: "Unset = open door (preview only)").

The Worker is deployed on a *.workers.dev hostname. When TWILIO_TOKEN is unset — the documented default — signature verification returns ok:true and any internet caller can POST /sms with arbitrary From, Body, NumMedia and MediaUrl0..9.

Exploit sketch:

POST https://sw-feedback-intake-preview.<sub>.workers.dev/sms
From=%2B15550000000&Body=<anything>&NumMedia=1
&MediaUrl0=https://attacker.example/500mb.jpg&MediaContentType0=image/jpeg

Result: the Worker fetches an arbitrary attacker-chosen URL (server-side request forgery from a Cloudflare egress, no host allow-list, no byte cap — index.js:79-86), writes the body to R2 (store.js:63-84), inserts a row in the shared production D1 (store.js:86-108), appends to the KV inbox, and fires the Discord webhook (store.js:136-153). Repeat for storage-cost abuse, forged "grandma feedback" that poisons triage, and Discord flooding. There is no rate limit on this route at all. (The Twilio Basic-auth header is not leakable this way: it is only attached when TWILIO_SID && TWILIO_TOKEN are both set, and a set token means signatures are enforced.)

Fix: invert the default — if (!token) return { ok: false, reason: 'not_configured' }, so a missing secret closes the door rather than opening it; add a media-host allow-list (api.twilio.com / mcs.us1.twilio.com) and a per-attachment byte cap before arrayBuffer(); put a cheap per-IP KV limiter in front of /sms; set workers_dev = false once the Email Routing rule exists.


MEDIUM

M1 — The generative session gate is opt-in and parsed fail-open

ASVS V1 (architecture), V4 src/lib/auth/guard.js:20 — requiresAccount = (env) => String(env?.GENERATIVE_REQUIRES_ACCOUNT).toLowerCase() === 'true'; :29-30 returns null (allow) when the var is anything else.

Production is currently correct — a signed-out POST /api/customize returned 401 {"code":"sign_in_required"} on 2026-09-02 — but the only thing standing between an anonymous internet and four metered endpoints (/api/customize, -art, -icons) is one dashboard string. A typo, a whitespace-padded value, a Preview environment that never got the var, or a var lost in a project migration silently reopens paid model traffic with no alert. Every other switch in the codebase defaults safe-by-absence except this one.

Fix: default to required — !== 'false' — so only an explicit opt-out disables it; log once per cold start when the gate is off; assert it in the deploy smoke.

M2 — No CSP, no framing policy, and unsanitised tray HTML re-parsed into an iframe

ASVS V14 (config), V5 (encoding) No public/_headers exists (repo has only public/_redirects). Confirmed on production GET /: no content-security-policy, no x-frame-options, no HSTS; access-control-allow-origin: * on the static HTML. src/lib/auth/state.js:49 stores custom[slug].html up to 400 KB with no sanitisation; src/lib/playset-custom.js:130 re-emits it as srcdoc="…" into a same-origin iframe.

Scope is honest: the tray is per-household and per-browser, so a script planted in custom.html executes in the planter's own session — this is self-XSS, not cross-user stored XSS (/api/state reads only the caller's own row, functions/api/state.js:34). But there is no CSP to blunt it, no frame-ancestors protection on /account/ (clickjacking the sign-in flow), and the srcdoc path is exactly where a future feature that shares a tray becomes a real stored XSS.

Fix: add public/_headers with a default-src 'self' CSP (script-src will need 'unsafe-inline' or hashes for the inline Astro scripts and challenges.cloudflare.com for Turnstile), X-Frame-Options: DENY (or frame-ancestors 'self'), Strict-Transport-Security, and a Permissions-Policy. Store the customized page as data (values/answers/art ids — all of which the tray already carries) and re-render it from the bundled manifest, rather than storing serialized HTML at all.

M3 — The child's name reaches the model and the database when it is typed in the wrong box

ASVS V8 (data protection) · COPPA-relevant src/lib/customize/engine.js redactName(text, name) (~line 100) removes only the name the parent typed into the name field; scrub() (~line 84) removes emails/URLs/@handles/addresses/ digit-runs but has no name logic. functions/api/customize.js:220 calls redactName(input.about, name) — with an empty name field this is a no-op.

The privacy page states, twice and unconditionally: "Your kid's name is never sent to an AI model" and "The name comes to our server so we can print it on the page, and it stops there." A parent who leaves the name box empty and writes "Mae is 4 and loves horses" sends "Mae" to Anthropic and stores it in D1 customizations.ask_stripped for 90 days (src/lib/ugc/log.js:100, default UGC_STORE_ASK true, :53).

Fix: either soften the copy to what the code does, or make the code match the copy — a given-name detector over the description (capitalised non-dictionary token before a first-person verb) that redacts on the way in, plus UGC_STORE_ASK=false until it exists.

M4 — household_hash is a stable IP-derived identifier retained 90 days

ASVS V8 · COPPA-relevant src/lib/ugc/log.js:57-61 — sha256(CUSTOMIZE_SALT + ip) with no day component, unlike the limiter key (functions/api/customize.js:105, which does include utcDay). It is written to every UGC row and kept for 90 days alongside the child's age band, language, interests and the stripped sentence.

The privacy page's "Fair use of the customizer" section says the IP hash is mixed with "the day's date" and is "a meaningless string of characters that expires the next day." That is true of the rate-limit key and false of the row that is actually retained. Salt in hand, the hash is trivially reversible over the IPv4 space.

Fix: either rotate the household hash on a coarse window (e.g. per-month) and say so, or drop the column and count distinct households from the limiter's own counters.

M5 — Retention is stamped but never enforced

ASVS V8 retain_until is written on both tables (src/lib/ugc/log.js:104, workers/feedback-intake/src/store.js:59) and nothing anywhere deletes on it — a repo-wide search for DELETE FROM returns zero hits outside tests. The KV fallback path does honour it via expirationTtl (log.js:141); the shipped D1 path does not.

The privacy page promises "Individual rows are deleted after 90 days"; the Worker README promises 180 days for feedback. Neither happens.

Fix: a scheduled Worker (or a step in the nightly triage script) running DELETE FROM customizations WHERE retain_until < ? and the same for feedback + the matching R2 photo keys; log the deleted count.

M6 — Abuse limits are keyed on hashed IP only, non-atomically, with a shared monthly ceiling

ASVS V11 (business logic), V4 functions/api/customize.js:105-115 (takeSlot read-modify-write, "not atomic; spec §6 accepts the slip"), :247-252; src/lib/auth/limits.js:19-24; functions/api/shopper.js:80-98.

Three consequences. (1) The 3/day gate is per-IP-hash, so a mobile connection, a VPN, or IPv6 address rotation resets it at will — even though the caller must now be signed in, the gate never looks at the household. (2) Read-modify-write means concurrent requests all read the same count; a burst of parallel POSTs overshoots the cap. (3) The real ceilings — CUSTOMIZE_MONTHLY_CEILING 1000 words, CUSTOMIZE_ART_MONTHLY_CEILING 300 images, shopper 1400 — are global, so one determined abuser exhausts the month and every legitimate parent gets "The pencil broke" until the 1st. That is a denial-of-wallet and a denial-of-feature primitive.

Fix: key the daily gate on household_id (available now that the session gate is on) with the IP hash as a secondary brake; use a Durable Object or KV cas-style guard for the monthly counter; add a per-household monthly sub-ceiling so no single account can drain the global one; alert at 70 % of the ceiling.

M7 — Sign-in limiter fails open; Turnstile is optional by secret presence

ASVS V2 (authentication) src/lib/auth/limits.js:38 (if (!kv || !secret) return { ok: true, reason: 'limiter_unavailable' }), :49-51 (any KV throw → allow), :58 (if (!env.TURNSTILE_SECRET) return { ok: true }).

The trade-off is argued in the comment and is defensible, but the composition is not: a KV namespace outage removes both the 5/hour-per-email and 20/hour-per-IP brakes from an endpoint that sends mail on your Resend reputation, and if TURNSTILE_SECRET is ever unset the last brake goes with it. The prod probe cannot distinguish "sent" from "blocked" (by design), so this failure mode is invisible from outside.

Fix: on limiter failure, fall back to a stricter posture rather than an open one (e.g. require a Turnstile token unconditionally when the limiter is unavailable); alarm on limiter_unavailable instead of swallowing it.


LOW

# Finding Location Note
L1 POST /api/downloads is unauthenticated and unlimited — anyone can inflate total:game / game:<slug> and drive unbounded KV writes functions/api/downloads.js:83-101 Instrumentation integrity: the kill/ship decision reads these numbers. Add an origin check + a cheap per-IP cap.
L2 DOWNLOADS_READ_KEY comparison leaks key length before the constant-time loop functions/api/downloads.js:107,133-137 Compare SHA-256 digests of both sides instead.
L3 One daily gate slot buys up to 4 image generations + 4 evaluator calls functions/api/customize-icons.js:231 (2 icons × 2 attempts, retries charge only the monthly counter) Cost amplification inside a unit the parent perceives as "one". Charge the daily gate per generation.
L4 ?debug=1 returns upstream stage, HTTP status and a 200-char provider error string to any signed-in caller functions/api/customize-icons.js:158-159, 236 No secrets, but it fingerprints the gateway and its providers. Gate on a header secret.
L5 Icon preload sets an Image().src from the API response without the isArtUrl check applied on the swap path src/scripts/customize.js:609 vs src/lib/customize/apply.js isArtUrl Defense-in-depth inconsistency; not exploitable while the server builds the URL.
L6 The magic-link token rides in a URL and lands in browser history / shared-device sessions functions/api/auth/callback.js:46-57 Well mitigated (referrer-policy: no-referrer, noindex, GET is inert, POST consumes). Residual risk is inherent to magic links.
L7 Feedback media fetched with no size cap; 8 photos/message, unlimited messages workers/feedback-intake/src/index.js:69-90, pipeline.js:26 R2 storage-cost abuse; compounds H1.
L8 The privacy page never mentions the feedback channel — feedback_contacts.sender_raw holds a raw email address or E.164 phone number, photos of a child's completed sheet go to R2, and the text is relayed to Discord migrations/0002_feedback.sql:61-67, store.js:136-153 vs src/pages/privacy.astro Fine while it is tester-only; must be disclosed before the address is published.
L9 access-control-allow-origin: * on static HTML (Cloudflare Pages default); no HSTS header observed production GET / API routes correctly send no ACAO. Cosmetic today; pin it in _headers.

Direct answers

(a) Can an anonymous visitor trigger metered model/image spend, and what caps bound it? For the three generative endpoints, no — as configured today. GENERATIVE_REQUIRES_ACCOUNT is on in production and the session gate is each handler's first act, before body parsing (functions/api/customize.js:203, customize-art.js:110, customize-icons.js:149); a signed-out POST /api/customize returned 401 sign_in_required on 2026-09-02. But /api/shopper is deliberately open (functions/api/shopper.js:164-175, founder ruling): any anonymous caller can spend one Claude call per request, braked only by an optional Turnstile, a per-IP-hash daily cap, an hourly burst cap, and a global monthly ceiling (default 1400) that degrades to a hand-built fallback set. For signed-in callers the bounds are 3 word-calls + 3 art-calls per IP-hash per day, 10/hour burst, and global monthly ceilings of 1000 word calls / 300 image generations. Two caveats carry real money: the daily gate is keyed on IP, not household (M6), so rotation defeats it; and the monthly ceilings are shared, so exhausting them is both the cost cap and a denial-of-service against every other parent.

(b) Can model output inject script or markup into a page a parent prints or views? No path found. Server-side, every changed model string is rejected for <, >, HTML entities, javascript: and backticks (engine.js hasMarkup + validateValues), for PII, for banned tokens, for unknown {{tokens}}, and for length/charset/language; declined items get the same markup check; any answers key the model sends is discarded and answers are recomputed by code on both sides (recompute.js, verified again client-side in apply.js verifyAnswers). Client-side, model values are written with textContent (apply.js writeText/applyPlan); the two innerHTML calls on that path carry our own generator SVG built from clamped integers and our own fixed copy strings, never model text. Image URLs are regex-pinned to ^/api/art/[0-9a-f]{64}$ both before the swap (apply.js isArtUrl) and in the client (customize.js:530), and /api/art/<id> serves only sniffed PNG/JPEG/WebP bytes with nosniff and content-disposition: inline. Prompt injection via the parent's sentence is contained rather than prevented: rule 8 of the system prompt tells the model the text is data, and — more importantly — the output schema is total and every field is validated, so a successful injection can at most produce a different sentence within the constraints, not markup and not an answer key. The only markup that survives to a rendered page is the tray's own serialized html in an iframe srcdoc (M2), which is the viewer's own content.

(c) Can anyone sign in as someone else, or fixate a session? No, on the evidence. Tokens are 32 bytes of crypto.getRandomValues (tokens.js:29-40); only a SESSION_SECRET-peppered SHA-256 is persisted; TTL is 15 minutes for links and 30 days for sessions; single use is enforced in SQL (UPDATE … WHERE used_at IS NULL), so a race has exactly one winner (confirm.js:81-82). GET /api/auth/callback is inert — it sets no cookie and touches no row — and the only consuming verb is a POST that requires same-origin (Sec-Fetch-Site authoritative, Origin fallback, neither header → refuse), which also blocks login-CSRF fixation. The cookie is __Host--prefixed, Secure, HttpOnly, SameSite=Lax, Path=/, no Domain, and is issued only at confirm — a caller cannot present a session value the server has not minted. next is validated at request time, stored on the row, and never read from the callback query, so there is no open redirect. Enumeration is closed by a single frozen 200 body on every non-malformed path. Residual: sessions are not bound to UA or IP (the UA hash is diagnostic only), and anyone holding the emailed link within 15 minutes is that household — the standard magic-link trade-off, correctly documented.

(d) Blast radius of a leaked CF_AIG_TOKEN / SESSION_SECRET / Resend key. CF_AIG_TOKEN — the worst financial leak: it authenticates to the Cloudflare AI Gateway which holds the Anthropic and xAI credentials (BYOK), so a holder spends your Claude and Grok budget at will, from anywhere, with none of the site's daily/monthly gates in front of them. It exposes no site data and no customer data. Rotate in the gateway; set gateway-side rate/spend limits so the token alone is not a blank cheque. SESSION_SECRET — a pepper, not a signing key: it hashes magic-link tokens, session cookies, IP hashes, UA hashes and the sign-in limiter keys. Alone it does not let anyone sign in (tokens are 256-bit random and the hash is one-way, and minting a session needs a D1 write). What it does buy is deanonymisation — the IPv4 space is small enough to brute-force sha256(secret + "ip:" + ip), so secret + a D1 dump turns every created_ip_hash back into a real IP — plus forgery of limiter keys to lock a specific address or IP out of sign-in. Combined with D1 write access it is game over for accounts; that combination is the scenario to design against. RESEND_API_KEY — send mail as any domain verified in that Resend account. Since the sign-in mail is itself the credential, the sharpest attack is a convincing phishing "sign-in link" from your own sender to your own users; it also burns sending reputation. It grants no read access to the site or its data. (Also worth listing with these: CUSTOMIZE_SALT and FEEDBACK_SALT — leaking either re-identifies every hashed IP / sender in the UGC and feedback tables.)

(e) COPPA-relevant: any child data collected that the privacy page doesn't state? No child accounts, no child logins, no kid profiles — the site is parent-facing and the schema has no child entity, which is the right shape. Three gaps against the stated policy:

  1. The child's first name can reach the model and the 90-day D1 row when the parent types it into the description instead of the name box (M3). The page says it never reaches a model.
  2. Age band, language, interests and the stripped sentence are retained 90 days against a stable IP-derived household_hash (M4). The page's only IP-hash statement is that it "expires the next day", which describes a different key.
  3. The feedback channel is undisclosed (L8): a raw email address or phone number in feedback_contacts, plus photographs of a child's completed and hand-annotated worksheet in R2 for 180 days, relayed to a Discord webhook. Tester-only today; must be on the page before feedback@scribbleworks.co is published anywhere. Add: the promised deletions do not currently execute (M5), so "deleted after 90 days" is, as of today, not a description of the system.

What I would fix first

  1. twilio.js:51 → fail closed. One line, removes the only unauthenticated write door. (H1)
  2. guard.js:20 → !== 'false'. One line, makes the money gate default-on. (M1)
  3. A public/_headers with CSP + X-Frame-Options + HSTS. (M2)
  4. The retention job that the privacy page already promises. (M5)
  5. Reconcile the three privacy-copy claims with the code — or the code with the copy. (M3, M4, L8)