Skip to content

Security

Markdown

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.

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.

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.

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:

  • https and http only, no credentials in the URL, and ports restricted to the default two.
  • Every IP literal rejected, in every notation — including hexadecimal forms like 0x7f000001 that a naive check misses.
  • Blocked hostnames (localhost and 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.254 is 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.

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.

Both are free, both take minutes, and both are worth doing on a directory that takes public submissions.

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.

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.

Listing bodies are third-party text, and a directory is a pile of other people’s marketing copy. Two consequences:

  • A listing slugged claude becomes claude.md on disk, and on a case-insensitive filesystem anything looking for a CLAUDE.md agent-memory file will find it. This is called out in the starter’s own AGENTS.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.

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.