From 63bedddc9424e9fac8ef3edf9c12da1a96714fe7 Mon Sep 17 00:00:00 2001 From: martbost Date: Fri, 11 Sep 2026 14:17:29 -0500 Subject: [PATCH] Sign-up guard: a filled honeypot triggers the icon check instead of a silent drop (form-filler extensions fill hidden fields) Co-Authored-By: Claude Fable 5.1 --- server.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server.js b/server.js index 9a541f3..a24f9a1 100644 --- a/server.js +++ b/server.js @@ -139,9 +139,10 @@ function codeChallenge(rec) { const guardLog = (req, why, b) => console.log('signup-guard', why, clientIp(req), String(b.email || '').replace(/^(.).*(@.*)$/, '$1***$2')); function codeGuard(req, b) { const now = Date.now(); - // honeypot: bots fill it, humans never see it. The field carries a nonsense name so browser - // autofill (which likes "website" and "url") cannot fill it for a real person. - if (b.hp_field_x9) { guardLog(req, 'honeypot', b); return { status: 200, body: { ok: true, sent: true } }; } + // honeypot: bots fill it, humans never see it. Some form-filler extensions fill every field, + // hidden or not (seen 2026-09-11), so a filled honeypot is a CHALLENGE, never a silent drop: + // a person passes the icon check and gets the code, a bot cannot. + const hp = !!b.hp_field_x9; const fts = Number(b.fts) || 0; if (!fts || now - fts < CODE_LIMITS.minFormMs) { guardLog(req, 'form-age', b); return { status: 400, body: { error: 'Give the page a second, then tap again.' } }; } if (now - fts > 12 * 3600 * 1000) { guardLog(req, 'form-stale', b); return { status: 400, body: { error: 'This page has been open a long time. Refresh it, then tap again.' } }; } @@ -152,12 +153,12 @@ function codeGuard(req, b) { const rec = codeHits.get(ip) || { t: [], passUntil: 0, chal: null }; rec.t = rec.t.filter(ts => now - ts < 24 * 3600 * 1000); const n10 = rec.t.filter(ts => now - ts < 10 * 60 * 1000).length; - const limited = n10 >= CODE_LIMITS.per10m || rec.t.length >= CODE_LIMITS.perDay; + const limited = hp || n10 >= CODE_LIMITS.per10m || rec.t.length >= CODE_LIMITS.perDay; if (limited && now >= rec.passUntil) { const pick = String(b.pick || ''); if (pick && rec.chal && rec.chal.exp > now && pick === rec.chal.answer) { rec.passUntil = now + CODE_LIMITS.passMs; rec.chal = null; } else { - guardLog(req, pick ? 'wrong-pick' : 'ip-limit', b); codeTrip(req, ip); + guardLog(req, pick ? 'wrong-pick' : hp ? 'honeypot-challenge' : 'ip-limit', b); if (!hp) codeTrip(req, ip); const challenge = codeChallenge(rec); codeHits.set(ip, rec); return { status: 429, body: { error: pick ? 'That was not it. Try once more.' : 'Quick check before we send another code.', challenge } }; }