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:
- 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.
- 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. - 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 beforefeedback@scribbleworks.cois 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
twilio.js:51→ fail closed. One line, removes the only unauthenticated write door. (H1)guard.js:20→!== 'false'. One line, makes the money gate default-on. (M1)- A
public/_headerswith CSP +X-Frame-Options+ HSTS. (M2) - The retention job that the privacy page already promises. (M5)
- Reconcile the three privacy-copy claims with the code — or the code with the copy. (M3, M4, L8)