diff --git a/db/migrations-bootstrap.sql b/db/migrations-bootstrap.sql index 83c330b..4b54253 100644 --- a/db/migrations-bootstrap.sql +++ b/db/migrations-bootstrap.sql @@ -17,8 +17,10 @@ -- that is what wrangler inserts after running a file. Both have to match -- exactly or wrangler will not recognize its own bookkeeping. -- --- Idempotent: `name` is UNIQUE and the inserts are OR IGNORE, so re-running --- this changes nothing. `applied_at` will say when the bootstrap ran rather +-- Idempotent, and safe on a database at any point in the sequence: `name` is +-- UNIQUE, the inserts are OR IGNORE, and each is guarded on the schema its +-- migration creates. Running it on an empty database records nothing, which is +-- correct -- `migrations apply` should then run all four. `applied_at` will say when the bootstrap ran rather -- than when each migration actually ran, which is the one thing it cannot -- reconstruct. -- @@ -32,7 +34,29 @@ CREATE TABLE IF NOT EXISTS "d1_migrations"( applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ); -INSERT OR IGNORE INTO d1_migrations (name) VALUES - ('0001_init.sql'), - ('0002_spam_and_delivery.sql'), - ('0003_email_domain.sql'); +-- Each row is recorded only if the schema that migration creates is actually +-- present. An unconditional list asserts rather than checks, and asserting is +-- wrong: a database that had 0001 and 0002 but not 0003 would be told 0003 was +-- applied, and `migrations apply` would then skip it forever. That happened on +-- a local database on 2026-09-03, which is why these are guarded. +-- +-- Each guard names something only its own migration creates. +INSERT OR IGNORE INTO d1_migrations (name) + SELECT '0001_init.sql' + WHERE EXISTS (SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'case_intake'); + +INSERT OR IGNORE INTO d1_migrations (name) + SELECT '0002_spam_and_delivery.sql' + WHERE EXISTS (SELECT 1 FROM pragma_table_info('case_intake') + WHERE name = 'spam_reason'); + +INSERT OR IGNORE INTO d1_migrations (name) + SELECT '0003_email_domain.sql' + WHERE EXISTS (SELECT 1 FROM pragma_table_info('case_intake') + WHERE name = 'email_domain'); + +INSERT OR IGNORE INTO d1_migrations (name) + SELECT '0004_retention_runs.sql' + WHERE EXISTS (SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'retention_runs'); diff --git a/sites/forensics/src/lib/form.ts b/sites/forensics/src/lib/form.ts index 254499f..3ddf238 100644 --- a/sites/forensics/src/lib/form.ts +++ b/sites/forensics/src/lib/form.ts @@ -40,8 +40,25 @@ export function looksLikeEmail(value: string): boolean { * is withheld. The response remains the same success redirect a person gets, so * the bot learns nothing and does not retry with the field left blank. */ +/** + * The honeypot field. + * + * Named `topic_ref` rather than `website`, and carrying the ignore attributes + * every major password manager honours, because on 2026-09-03 a real + * submission to the forensics form was classified as spam: 1Password filled a + * field labelled "Website" with the site's own URL. Password managers match on + * name, id and label text, and they ignore `autocomplete="off"`; `tabindex=-1` + * only stops keyboard focus. Any name containing website, url, address, phone, + * company, user or email is a target, so the field is named after nothing. + * + * `fax_number` is the name the common writeups suggest, on the grounds that + * fax is obsolete enough to be ignored. It was not taken: Chrome's address + * autofill has carried a fax field type, and phone and fax sit inside the + * address group that gets filled together. A name matching no heuristic at all + * is a stronger guarantee than one that is merely unfashionable. + */ export function honeypotValue(form: FormData): string { - return field(form, 'website', 100) + return field(form, 'topic_ref', 100) } /** The input the Turnstile widget injects into the form it wraps. */ diff --git a/sites/forensics/src/pages/api/intake.ts b/sites/forensics/src/pages/api/intake.ts index e90fc3d..bcf907c 100644 --- a/sites/forensics/src/pages/api/intake.ts +++ b/sites/forensics/src/pages/api/intake.ts @@ -65,11 +65,23 @@ export const POST: APIRoute = async ({ request }) => { // Field validation is skipped for spam, so that a malformed bot submission // still leaves the row it is being stored for. - if (!verdict.reason && (!name || !summary || !looksLikeEmail(email))) { + // Only a failed Turnstile holds a submission back. A honeypot hit on its own + // is recorded and still delivered: the assumption that a person never fills a + // hidden field was disproved on 2026-09-03 by a password manager, and the + // failure was silent -- the row was stored with status 'held' and nobody was + // told an enquiry had arrived. Turnstile is the real bot gate; the honeypot + // is now a signal on a delivered message rather than a verdict that buries + // one. + const heldForSpam = verdict.reason?.includes('turnstile') ?? false + + // Validation is skipped only for a submission that is being stored purely as + // evidence. A honeypot-flagged row is still delivered, so it still has to be + // a usable enquiry. + if (!heldForSpam && (!name || !summary || !looksLikeEmail(email))) { return redirectTo('/scope?error=invalid') } - const status = verdict.reason ? 'held' : 'pending' + const status = heldForSpam ? 'held' : 'pending' let rowId: number | null = null try { @@ -105,13 +117,14 @@ export const POST: APIRoute = async ({ request }) => { return redirectTo('/scope?error=server') } - if (verdict.reason) { - return verdict.reason.includes('turnstile') - ? redirectTo('/scope?error=captcha') - : redirectTo('/scope?sent=1') - } + // A person who failed the challenge is told to retry rather than thanked for + // a message that will never be read. + if (heldForSpam) return redirectTo('/scope?error=captcha') - await notify({ rowId, name, email, firm, engagementType, summary, timing, referral, country }) + await notify({ + rowId, name, email, firm, engagementType, summary, timing, referral, country, + flagDetail: verdict.detail, + }) return redirectTo('/scope?sent=1') } @@ -136,6 +149,8 @@ async function notify(submission: { timing: string referral: string country: string | null + /** Set when a spam check fired on a submission we are delivering anyway. */ + flagDetail: string | null }): Promise { const outcome = await sendNotification( { @@ -145,7 +160,10 @@ async function notify(submission: { cc: env.NOTIFY_CC, }, { - subject: `[forensics] Case intake — ${submission.name}`, + // The flag leads the subject so a filter can act on it, and the reason + // travels in the body so triage does not need the database to interpret + // it. + subject: `${submission.flagDetail ? '[flagged] ' : ''}[forensics] Case intake — ${submission.name}`, replyTo: submission.email, text: formatBody([ ['Name', submission.name], @@ -156,6 +174,7 @@ async function notify(submission: { ['Referral', submission.referral], ['Country', submission.country], ['Row', submission.rowId ? String(submission.rowId) : null], + ['Spam check', submission.flagDetail], ['', ''], ['Matter', `\n${submission.summary}`], ]), diff --git a/sites/forensics/src/pages/scope.astro b/sites/forensics/src/pages/scope.astro index 55c8a52..3e5c5c4 100644 --- a/sites/forensics/src/pages/scope.astro +++ b/sites/forensics/src/pages/scope.astro @@ -127,8 +127,22 @@ const turnstileSiteKey = env.TURNSTILE_SITE_KEY {/* Honeypot — off-screen rather than display:none, which some bots know to skip. */} {/* Implicit rendering: the script finds this container and injects diff --git a/sites/www/src/lib/form.ts b/sites/www/src/lib/form.ts index 0b02a44..990395a 100644 --- a/sites/www/src/lib/form.ts +++ b/sites/www/src/lib/form.ts @@ -48,8 +48,25 @@ export function looksLikeEmail(value: string): boolean { * is withheld. The response remains the same success redirect a person gets, so * the bot learns nothing and does not retry with the field left blank. */ +/** + * The honeypot field. + * + * Named `topic_ref` rather than `website`, and carrying the ignore attributes + * every major password manager honours, because on 2026-09-03 a real + * submission to the forensics form was classified as spam: 1Password filled a + * field labelled "Website" with the site's own URL. Password managers match on + * name, id and label text, and they ignore `autocomplete="off"`; `tabindex=-1` + * only stops keyboard focus. Any name containing website, url, address, phone, + * company, user or email is a target, so the field is named after nothing. + * + * `fax_number` is the name the common writeups suggest, on the grounds that + * fax is obsolete enough to be ignored. It was not taken: Chrome's address + * autofill has carried a fax field type, and phone and fax sit inside the + * address group that gets filled together. A name matching no heuristic at all + * is a stronger guarantee than one that is merely unfashionable. + */ export function honeypotValue(form: FormData): string { - return field(form, 'website', 100) + return field(form, 'topic_ref', 100) } /** The input the Turnstile widget injects into the form it wraps. */ diff --git a/sites/www/src/pages/api/contact.ts b/sites/www/src/pages/api/contact.ts index 9e479fd..8537b09 100644 --- a/sites/www/src/pages/api/contact.ts +++ b/sites/www/src/pages/api/contact.ts @@ -46,13 +46,24 @@ export const POST: APIRoute = async ({ request }) => { const email = field(form, 'email', LIMITS.email) const message = field(form, 'message', LIMITS.message) - // Field validation is skipped for spam. Rejecting a bot for a malformed email - // address would discard the row, and the row is the reason for storing it. - if (!verdict.reason && (!name || !message || !looksLikeEmail(email))) { + // Only a failed Turnstile holds a submission back. A honeypot hit on its own + // is recorded and still delivered: the assumption that a person never fills a + // hidden field was disproved on 2026-09-03 by a password manager, and the + // failure was silent -- the row was stored with status 'held' and nobody was + // told an enquiry had arrived. Turnstile is the real bot gate; the honeypot + // is now a signal on a delivered message rather than a verdict that buries + // one. + const heldForSpam = verdict.reason?.includes('turnstile') ?? false + + // Validation is skipped only for a submission being stored purely as + // evidence. Rejecting a bot for a malformed email address would discard the + // row, and the row is the reason for storing it. A honeypot-flagged row is + // delivered, so it still has to be a usable enquiry. + if (!heldForSpam && (!name || !message || !looksLikeEmail(email))) { return redirectTo('/contact?error=invalid') } - const status = verdict.reason ? 'held' : 'pending' + const status = heldForSpam ? 'held' : 'pending' let rowId: number | null = null try { @@ -74,18 +85,11 @@ export const POST: APIRoute = async ({ request }) => { return redirectTo('/contact?error=server') } - if (verdict.reason) { - // A person does not fill a hidden field, so a honeypot hit has effectively - // no false positives and gets the same success redirect as a real - // submission -- the bot learns nothing. Turnstile does produce false - // positives, so a person who failed the challenge is told to retry rather - // than thanked for a message that will never be read. - return verdict.reason.includes('turnstile') - ? redirectTo('/contact?error=captcha') - : redirectTo('/contact?sent=1') - } + // Turnstile produces false positives, so a person who failed the challenge is + // told to retry rather than thanked for a message that will never be read. + if (heldForSpam) return redirectTo('/contact?error=captcha') - await notify({ rowId, name, email, message, country }) + await notify({ rowId, name, email, message, country, flagDetail: verdict.detail }) return redirectTo('/contact?sent=1') } @@ -103,6 +107,8 @@ async function notify(submission: { email: string message: string country: string | null + /** Set when a spam check fired on a submission we are delivering anyway. */ + flagDetail: string | null }): Promise { const outcome = await sendNotification( { @@ -111,13 +117,15 @@ async function notify(submission: { to: env.NOTIFY_TO, }, { - subject: `[rootsystem.com] Contact — ${submission.name}`, + // The flag leads the subject so a filter can act on it. + subject: `${submission.flagDetail ? '[flagged] ' : ''}[rootsystem.com] Contact — ${submission.name}`, replyTo: submission.email, text: formatBody([ ['Name', submission.name], ['Email', submission.email], ['Country', submission.country], ['Row', submission.rowId ? String(submission.rowId) : null], + ['Spam check', submission.flagDetail], ['', ''], ['Message', `\n${submission.message}`], ]), diff --git a/sites/www/src/pages/contact.astro b/sites/www/src/pages/contact.astro index d48ce74..63b1b07 100644 --- a/sites/www/src/pages/contact.astro +++ b/sites/www/src/pages/contact.astro @@ -74,8 +74,22 @@ const turnstileSiteKey = env.TURNSTILE_SITE_KEY {/* Honeypot. Hidden from people, filled by bots. Not `type=hidden`, which bots skip -- it must look like a real field to a scraper. */} {/* Implicit rendering: the script finds this container and injects