From 288e059a0ea51a7a791ad79c004653bd5ed6b6c4 Mon Sep 17 00:00:00 2001 From: Rob Jacques <2531474+robbyrob42@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:20:31 -0700 Subject: [PATCH] fix(forms): a password manager filled the honeypot and buried the lead The first real submission to the live intake form was classified as spam. 1Password filled the hidden field -- named `website`, labelled "Website" -- with the site's own URL, and the row was stored with status 'held', which means no notification is sent. The enquiry existed in the database and nobody was told it had arrived. Two faults, and the silent one is the worse of them. The field was a magnet. Password managers match on name, id and label text, and they ignore autocomplete="off"; tabindex="-1" only stops keyboard focus. It is renamed `topic_ref` -- after nothing, since any name containing website, url, address, phone, company, user or email is a target -- and now carries autocomplete="new-password" rather than "off", plus the ignore attributes 1Password, LastPass and Dashlane honour, aria-hidden, and off-screen positioning. "new-password" is the change that matters, and it came from a writeup Rob found. Browsers ignore the "off" value; every vendor implements suppression while someone is choosing a new password, so that value is respected where the obvious one is not. That writeup suggests naming the field `fax_number`, on the grounds that fax is obsolete enough to be ignored. 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. The real fields keep their proper autofill tokens -- name, email and organization. An attorney filling this form should get autofill; only the trap is suppressed. The verdict was too strong. The code asserted that "a person does not fill a hidden field, so a honeypot hit has effectively no false positives", and that is now disproved. Turnstile is the real bot gate, so a honeypot hit on its own no longer holds a submission back: the row records the reason, the notification is still sent, and the subject leads with [flagged] so a filter can act on it. Only a failed Turnstile still holds. Validation now runs on anything being delivered, since a flagged row still has to be a usable enquiry. Both properties carried the identical field, so rootsystem.com's contact form has had this since Track 1 shipped in early August. Separately, db/migrations-bootstrap.sql now guards each row on the schema its migration creates. It asserted all three were applied, and a local database that had 0001 and 0002 but not 0003 was told 0003 was done, which would have skipped it forever. Each insert is now conditional on something only that migration creates, so the file is safe at any point in the sequence and records nothing on an empty database. Verified locally against wrangler dev: a submission with the honeypot filled is stored with spam_reason 'honeypot' and status 'pending' rather than 'held', a clean submission is unaffected, and email_domain is derived correctly in both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv --- db/migrations-bootstrap.sql | 36 ++++++++++++++++++---- sites/forensics/src/lib/form.ts | 19 +++++++++++- sites/forensics/src/pages/api/intake.ts | 37 +++++++++++++++++------ sites/forensics/src/pages/scope.astro | 18 +++++++++-- sites/www/src/lib/form.ts | 19 +++++++++++- sites/www/src/pages/api/contact.ts | 40 +++++++++++++++---------- sites/www/src/pages/contact.astro | 18 +++++++++-- 7 files changed, 150 insertions(+), 37 deletions(-) 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