Elevation Craft

Design notes / ecfesa.org

Four gates before any work happens.

A volunteer fire-district nonprofit runs its public site on a $0/month stack. That budget buys no WAF, no bot-management tier, no security vendor — so the hardening had to be structural: layered where untrusted input actually enters, and honest about what each layer can’t do.

Public write routes
4
Gates before work
4
Rate ceiling / IP
5 / 10m
Automated tests
177
Monthly spend
$0

Plate 01 — Topology

Three lanes, one trust boundary

Everything the site does falls into three lanes, and they have completely different threat profiles. The read lane serves prerendered pages and touches no user input. The write lane accepts anonymous POSTs from the open internet — that’s where the gates live. The admin lane accepts no anonymous traffic at all.

Drawing them separately is the point: a control that belongs on the write lane is wasted on the read lane, and the admin lane needs a different kind of protection entirely.

UNTRUSTEDEDGE + APPLICATIONTRUSTED / OUTBOUNDSite visitorsForm submissionshumans + bots, indistinguishableBoard membersGoogle accountsPrerendered pagesISR, 60s revalidateGETno user input reaches code4 gates — see Plate 02schema · honeypotTurnstile · rate limitNode runtime, never edgePOSTSession gatemiddleware on /admin/*3 stackable rolesevery mutation auditedOAuthServer actions & route handlers — validated input onlyPostgres (Neon)Resend (email)Google Calendarread-only, one-wayDonorboxsigned webhook
Plate 01Accent marks the anonymous-write path and the controls that guard it. The read lane can't be attacked through input it never accepts; the admin lane is defended by identity, not by gates.

Plate 02 — The gauntlet

Cheapest check first, and the honeypot lies

Every anonymous POST runs the same ordered gauntlet, and the ordering is deliberate: schema parsing costs microseconds, a Turnstile verification costs a network round trip. Rejecting garbage early means the expensive checks only ever run on plausible traffic.

The honeypot is the interesting one. A filled hidden field returns 200 OK — the same response a real signup gets. The bot records a success and moves on, never learning the field is a tell, and no database write or email send happens. Every other gate answers with a status code that tells the truth.

POSTanonymous1 · SchemaZod, strict shape400malformed2 · Honeypothidden field200 OKsilent drop —nothing written3 · Turnstileserver-verified400 / 503failed, or failsclosed in prod4 · Rate limit5 / 10 min / IP429Retry-AfterworkbeginsAFTER THE GATESRow written as pendingSigned opt-in link mailed — 24h expiry
Plate 02Gates run in cost order and short-circuit. Only gate 2 lies about the outcome — every other rejection returns an honest code. A success response is deliberately identical whether the address is new or already subscribed, so the endpoint can't be used to enumerate the list.

Plate 03 — Identity

Two proofs to become an admin

The site is run by volunteers whose accounts are ordinary personal Gmail addresses, so “everyone on the Workspace domain” was never going to be the whole answer. But an open door keyed on an email address is a door anyone can knock on.

So an invitation requires two independent proofs before it becomes an account: possession of the mailbox (clicking a signed link that was only ever sent there) and control of the Google account for that same address. One without the other gets nothing. Roles are chosen by the inviter up front and applied by the system at account creation — never selected by the person signing up.

WHO MAY HOLD A SESSIONWorkspace domain claimExisting admin recordConfirmed invitationSign-in checkfails closedrefusedevery other Google accountRole required per actionsuper · content · membershiproles stack; a session with no role canread the admin area and change nothingevery mutation writes an audit row:who, what, before, afterHOW AN INVITATION BECOMES AN ACCOUNTSuper adminpicks roles up frontProof 1 — mailboxsigned link, 7-day expiryonly the hash is storedProof 2 — accountGoogle sign-in as thatexact addressAccount created, roles appliedinvitation consumed, single use
Plate 03An invitation is inert until both proofs land, and it can only be spent once. Because the invited address may be a personal Gmail account, the domain claim is one admissible path — not the only one.

Ledger

Every control, and what it doesn't do

Layers get chosen for what they stop and for how they fail. Listing the limitation next to the control is the part that keeps the design honest: a control whose weakness nobody has written down tends to get trusted for more than it’s worth.

ControlWhat it stopsKnown limitation
Schema validation, every routeMalformed and oversized payloads reaching any logicNone worth noting — it's cheap and total
Honeypot fieldSilentBots that fill every input they can findA bot that renders CSS skips it entirely
Turnstile, verified server-sideScripted submissions at any volumeCan false-positive on hardened browsers; admins can activate a stuck signup by hand
Fail-closed secret handlingBypass by removing a key from the environmentCosts a hard outage if a key is ever lost — the intended trade
Per-IP token bucketVolume attacks that would burn the mail provider's daily capHeld in process memory: the ceiling is per warm instance, not global
Double opt-in, signed tokenAdding someone else's address to the listOnly as strong as the signing key's hygiene
Hash stored, never the tokenA database leak turning into forged confirmationsLegitimate links can't be recovered, only reissued
Constant-time comparisonTiming oracles against tokens and webhook signatures
Deliberately ambiguous successUsing the signup endpoint to enumerate membersRepeat subscribers get a slightly vaguer confirmation
Webhook signature + replay windowForged or replayed payment eventsDepends on both clocks staying sane
Sanitized rendering of all authored textStored XSS arriving via calendar or admin copyBlocks legitimate embeds too — by design
Read-only calendar credentialA leaked key escalating into calendar takeoverGranting a new editor calendar access stays a manual step

The rate limiter is the layer most likely to be replaced first. It’s in-memory by choice — a database-backed sliding window is a swap behind the same function signature — but until abuse justifies the write traffic, an imperfect bound that costs nothing beats a perfect one that costs a query per submission.

Posture

One-way by construction

The calendar integration is the clearest example of hardening that came from architecture rather than from a control. Event content is authored in Google Calendar and mirrored into the site; nothing the website does ever writes back. The service account holds a read-only scope and no project permissions at all — its access comes from the calendar being shared with it, the same way it would be shared with a person.

That means the blast radius of a leaked calendar key is: someone reads events that are already published on a public website. Sync runs on demand from the admin UI, with a once-daily backstop — no standing automation with credentials to steal, and no path from the web tier into the org’s Workspace data.