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.
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.
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.
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.
| Control | What it stops | Known limitation |
|---|---|---|
| Schema validation, every route | Malformed and oversized payloads reaching any logic | None worth noting — it's cheap and total |
| Honeypot fieldSilent | Bots that fill every input they can find | A bot that renders CSS skips it entirely |
| Turnstile, verified server-side | Scripted submissions at any volume | Can false-positive on hardened browsers; admins can activate a stuck signup by hand |
| Fail-closed secret handling | Bypass by removing a key from the environment | Costs a hard outage if a key is ever lost — the intended trade |
| Per-IP token bucket | Volume attacks that would burn the mail provider's daily cap | Held in process memory: the ceiling is per warm instance, not global |
| Double opt-in, signed token | Adding someone else's address to the list | Only as strong as the signing key's hygiene |
| Hash stored, never the token | A database leak turning into forged confirmations | Legitimate links can't be recovered, only reissued |
| Constant-time comparison | Timing oracles against tokens and webhook signatures | — |
| Deliberately ambiguous success | Using the signup endpoint to enumerate members | Repeat subscribers get a slightly vaguer confirmation |
| Webhook signature + replay window | Forged or replayed payment events | Depends on both clocks staying sane |
| Sanitized rendering of all authored text | Stored XSS arriving via calendar or admin copy | Blocks legitimate embeds too — by design |
| Read-only calendar credential | A leaked key escalating into calendar takeover | Granting 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.