Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions db/migrations-bootstrap.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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.
--
Expand All @@ -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');
19 changes: 18 additions & 1 deletion sites/forensics/src/lib/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
37 changes: 28 additions & 9 deletions sites/forensics/src/pages/api/intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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')
}
Expand All @@ -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<void> {
const outcome = await sendNotification(
{
Expand All @@ -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],
Expand All @@ -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}`],
]),
Expand Down
18 changes: 16 additions & 2 deletions sites/forensics/src/pages/scope.astro
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,22 @@ const turnstileSiteKey = env.TURNSTILE_SITE_KEY
{/* Honeypot — off-screen rather than display:none, which some bots
know to skip. */}
<div class="decoy" aria-hidden="true">
<label for="website">Website</label>
<input id="website" name="website" type="text" tabindex="-1" autocomplete="off" />
<label for="topic_ref">Leave this field blank</label>
<input
id="topic_ref"
name="topic_ref"
type="text"
tabindex="-1"
aria-hidden="true"
{/* Browsers ignore the "off" value here. "new-password" is
respected universally, because suppressing a suggestion while
someone chooses a new password is the one case every vendor
implements. The data attributes are belt to that brace. */}
autocomplete="new-password"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>

{/* Implicit rendering: the script finds this container and injects
Expand Down
19 changes: 18 additions & 1 deletion sites/www/src/lib/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
40 changes: 24 additions & 16 deletions sites/www/src/pages/api/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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')
}
Expand All @@ -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<void> {
const outcome = await sendNotification(
{
Expand All @@ -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}`],
]),
Expand Down
18 changes: 16 additions & 2 deletions sites/www/src/pages/contact.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<div class="decoy" aria-hidden="true">
<label for="website">Website</label>
<input id="website" name="website" type="text" tabindex="-1" autocomplete="off" />
<label for="topic_ref">Leave this field blank</label>
<input
id="topic_ref"
name="topic_ref"
type="text"
tabindex="-1"
aria-hidden="true"
{/* Browsers ignore the "off" value here. "new-password" is
respected universally, because suppressing a suggestion while
someone chooses a new password is the one case every vendor
implements. The data attributes are belt to that brace. */}
autocomplete="new-password"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>

{/* Implicit rendering: the script finds this container and injects
Expand Down
Loading