# Guestbook spam controls (davidunderwood.net) Endpoint: `guestbook.aspx` (ASP.NET Web Forms, inline C#). Storage: `App_Data/guestbook.json` (not browsable). Rate file: `App_Data/ratelimit.json`. Signing key: a server-only file under `App_Data/` (never in git). Loader: `App_Code/GuestbookSigningKey.cs`. ## Controls 1. **Honeypot** — hidden `website` field. Bots that fill it get a fake success; nothing is stored. 2. **Timed challenge** — form includes server-issued UTC ticks. Submits faster than **3 seconds** or older than **30 minutes** are rejected. 3. **Signed math CAPTCHA** — random `a + b` with an HMAC-SHA256 token over `ticks|sum`, keyed with the server-side signing key (see below). Answer must match and token must verify (prevents forging challenges). 4. **IP rate limit** — max **3** successful posts per client IP per rolling hour (`X-Forwarded-For` first hop if present, else `UserHostAddress`). 5. **Input hygiene** — strip tags/control chars; cap name 40 / message 280; HTML-encode on render; keep at most 200 entries. ## Ops notes - Requires ASP.NET 4.x so `.aspx` executes. Static HTML still works without it; the guestbook will not. - Deploys must **not** delete live `App_Data/*.json`. - Signing key: see **Signing key** below. Rotate it if it ever leaks; open forms just fail the math check once and re-issue on the next GET. - Server-specific setup (paths, identity, permissions) is in the private Ops notes, `App_Data/OPS.md`, which is never served. ## Signing key The HMAC key is **not** in the repo. `guestbook.aspx` reads it on every request from: 1. A key file in `App_Data/` (preferred). If this file exists it is authoritative. 2. Otherwise the appSettings key `GuestbookSigningKey`, used **only** when the key file definitely does not exist. If `App_Data` cannot be opened or listed, the guestbook fails closed instead of falling back. The tracked `web.config` is overwritten on every deploy, so only use this from config that is not deployed from git. **Fail closed.** If the key is missing, empty, under 32 characters, unreadable, or equal to the old key that was published in git history (checked by SHA-256 hash, plaintext not stored), the page accepts no submissions, shows "guestbook temporarily closed", and still lists existing notes. It writes `action=guestbook_closed status= source=...` to `App_Data/guestbook.log` (at most once per 15 minutes per state) and to `System.Diagnostics.Trace`. The key value is never logged. **Format.** One line of at least 32 random characters. Use 48 random bytes encoded as base64url (64 chars) or hex (96 chars). No quotes. UTF-8 without a BOM is preferred, but surrounding whitespace, a trailing newline, and a BOM are trimmed anyway. Never reuse the old value, which is rejected anyway. The Ops steps to generate and place it are in `App_Data/OPS.md` (not served). ## Manual test - Human: load page → wait 3s → solve math → submit → entry appears. - Bot-fast: submit immediately → rejected. - Honeypot: fill `website` → fake thanks, no entry. - Wrong math → rejected. - Fourth post in an hour from same IP → rate limited. - Key file missing / too short → "temporarily closed", no form, entries still listed, one `guestbook_closed` line in `App_Data/guestbook.log`. Automated: `bash tests/check-guestbook.sh` (static checks; plus C# compile check and unit tests when the .NET 8 SDK is installed).