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 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-11 14:17:29 -05:00
parent cd26161db5
commit 63bedddc94
+6 -5
View File
@@ -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')); const guardLog = (req, why, b) => console.log('signup-guard', why, clientIp(req), String(b.email || '').replace(/^(.).*(@.*)$/, '$1***$2'));
function codeGuard(req, b) { function codeGuard(req, b) {
const now = Date.now(); const now = Date.now();
// honeypot: bots fill it, humans never see it. The field carries a nonsense name so browser // honeypot: bots fill it, humans never see it. Some form-filler extensions fill every field,
// autofill (which likes "website" and "url") cannot fill it for a real person. // hidden or not (seen 2026-09-11), so a filled honeypot is a CHALLENGE, never a silent drop:
if (b.hp_field_x9) { guardLog(req, 'honeypot', b); return { status: 200, body: { ok: true, sent: true } }; } // 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; 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 (!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.' } }; } 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 }; const rec = codeHits.get(ip) || { t: [], passUntil: 0, chal: null };
rec.t = rec.t.filter(ts => now - ts < 24 * 3600 * 1000); rec.t = rec.t.filter(ts => now - ts < 24 * 3600 * 1000);
const n10 = rec.t.filter(ts => now - ts < 10 * 60 * 1000).length; 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) { if (limited && now >= rec.passUntil) {
const pick = String(b.pick || ''); 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; } if (pick && rec.chal && rec.chal.exp > now && pick === rec.chal.answer) { rec.passUntil = now + CODE_LIMITS.passMs; rec.chal = null; }
else { 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); 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 } }; return { status: 429, body: { error: pick ? 'That was not it. Try once more.' : 'Quick check before we send another code.', challenge } };
} }