Security
A public directory has one privileged route, one endpoint that fetches arbitrary URLs on request, and a form anyone on the internet can post to. This page is about those three, and about the two Cloudflare features worth turning on around them.
Admin authentication
Section titled “Admin authentication”One administrator, one long random secret, one signed cookie.
ADMIN_SECRET must be at least 32 characters; anything shorter is treated as unset and every
login is refused. The comparison hashes both sides and compares in constant time — never with
===.
The session cookie is HttpOnly, Secure, SameSite=Lax, and carries an expiry inside an HMAC
signature:
zd_admin=<expiry>.<HMAC-SHA256(TOKEN_SECRET, "admin:" + expiry)>It is signed with TOKEN_SECRET, not with ADMIN_SECRET. The login credential is never also
a signing key, so a signing bug cannot leak the thing you type in.
Middleware guards /admin/** and the action endpoint, and only pays for the HMAC when a cookie is
actually present. Every admin response carries Cache-Control: no-store and
X-Robots-Tag: noindex; every action re-checks the session rather than trusting the middleware.
Failed logins are audited with a hashed IP — the first 32 hex characters of a salted SHA-256 — so you can see a pattern without keeping addresses.
Why there is no password hash
Section titled “Why there is no password hash”The Workers free plan gives a request 10 ms of CPU, shared with rendering the page. PBKDF2 at 100,000 iterations costs about 13 ms on its own; scrypt at typical parameters, about 72 ms. Either one turns every login into a failed request.
A password hash protects a stored credential against an attacker who has the database. Here the credential is a 32-byte random secret that you never chose and never reused, held in Cloudflare’s secret store, and it is compared in constant time. Hashing it would cost the login and buy very little.
The trade this leaves open is online guessing, and the answer to that is a rate-limit rule at the edge rather than CPU in the request.
The metadata fetcher
Section titled “The metadata fetcher”Step 1 of the submission form takes a URL from a stranger and fetches it. That is a server-side request forgery primitive unless it is fenced, so it is:
httpsandhttponly, no credentials in the URL, and ports restricted to the default two.- Every IP literal rejected, in every notation — including hexadecimal forms like
0x7f000001that a naive check misses. - Blocked hostnames (
localhostand friends) and blocked suffixes:.local,.internal,.intranet,.lan,.home,.home.arpa,.corp,.private,.test,.example,.invalid,.onion. A hostname with no dot at all is rejected. - Bounded: 5-second timeout, 256 KB maximum response, at most 3 redirects.
- Re-checked on every redirect hop. Redirects are followed by hand rather than by the runtime,
because a permitted URL that redirects to
169.254.169.254is the whole attack.
The same check runs on the remote-logo fetch and again when the final submission is validated, not only on the prefill.
Uploads are content-sniffed; the declared content type is not trusted. Logos are capped by
your limits.logoMaxBytes and again by a hard ceiling in code, and the stream is cut at the cap
rather than read and then measured.
Abuse controls in the submission flow
Section titled “Abuse controls in the submission flow”| Control | Default | Notes |
|---|---|---|
| Submissions per email | 3 per rolling 24 hours | rolling, so midnight is not a reset button |
| One live listing per host | — | duplicate check against pending and approved rows; a rejected row does not block a resubmission |
| Honeypot | on | on both form steps and the newsletter form |
| Unpaid submissions purged | after 7 days | rows and uploaded files |
| Description / name / tagline caps | 2000 / 80 / 160 characters | |
| Metadata fetch | 256 KB, 5 s, 3 redirects |
Cross-site request forgery is covered by same-origin form posts, SameSite=Lax and Astro’s origin
check. The click endpoint writes its own origin check on top, because a text/ping request is not
form-shaped and the framework’s check does not apply to it.
There is no login rate limit in code. That is deliberate — the answer is the WAF rule below — but it means a deployment without that rule has an unlimited-attempt login form protected only by the entropy of a 32-byte secret. Which is a lot of entropy, and still worth the five minutes.
Two optional Cloudflare steps
Section titled “Two optional Cloudflare steps”Both are free, both take minutes, and both are worth doing on a directory that takes public submissions.
1. Put /admin/ behind Cloudflare Access
Section titled “1. Put /admin/ behind Cloudflare Access”Cloudflare Zero Trust includes 50 free seats, and you need one. Access puts an identity check in front of the admin routes before a request ever reaches your Worker, so the admin secret stops being the only thing between the internet and your queue.
2. One WAF rate-limiting rule on /admin/login/
Section titled “2. One WAF rate-limiting rule on /admin/login/”The Cloudflare free plan includes exactly one rate-limiting rule, and its expression can match on path and verified-bot status, counting by IP. That is enough for the one thing that needs it.
Point it at /admin/login/. It is the only unauthenticated endpoint where guessing is worth
anything, and it is why there is no rate limit in the request path.
The submission form is a different problem — it is meant to be used by strangers — and it is defended by the honeypot, the per-email limit and the duplicate check instead.
Secret hygiene
Section titled “Secret hygiene”Three token types, and keeping them distinct is the point:
| Token | Scope | Never also |
|---|---|---|
D1_READ_TOKEN |
D1 Read, used by the build | a deploy token |
| Workers Builds deploy token | deploy | a data-read token |
| Runtime secrets | the Worker at request time | build variables |
A build-time token that can also deploy turns a leaked CI log into a deploy. pnpm cf:secrets
prompts for each key and pipes what you type straight to wrangler, without printing it or
writing it anywhere — it never reads .dev.vars. That file is gitignored; only its .example
twin is committed, and the values in it are placeholders, not secrets. Replace both of the
generated ones before you deploy: /admin/ reports an error while either is still the example,
because every copy of this starter has the same pair.
A Cloudflare rule underneath all of this: a public astro:env field is inlined into your bundle
as a constant at build time. Anything that must stay secret has to be declared as a server
secret and read at request time — which the starter enforces by declaring every runtime field that
way and by keeping the one module that reads them isolated.
One thing to know if you generate content
Section titled “One thing to know if you generate content”Listing bodies are third-party text, and a directory is a pile of other people’s marketing copy. Two consequences:
- A listing slugged
claudebecomesclaude.mdon disk, and on a case-insensitive filesystem anything looking for aCLAUDE.mdagent-memory file will find it. This is called out in the starter’s ownAGENTS.md. - Text you fetched is data, not instruction. The content-generation skill says so explicitly, and it is the right default whether or not you use it.
Not included
Section titled “Not included”No Turnstile. The honeypot plus the per-email limit is the v1 answer; adding Turnstile is a small change and a reasonable one if you get a determined spammer.
No two-factor authentication. Cloudflare Access is the answer, and it does it better than a bespoke implementation would.
No per-IP submission limit. Per-email plus the host duplicate check covers the realistic case without keeping addresses.