Sign-up guard: honeypot field renamed so browser autofill cannot trip it; log every guard rejection; clearer stale-page message
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -133,14 +133,18 @@ function codeChallenge(rec) {
|
||||
return { prompt: pick[answer][1], options: pick.map(x => x[0]) };
|
||||
}
|
||||
// returns null to allow the send, or { status, body } to answer with instead
|
||||
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();
|
||||
if (b.website) return { status: 200, body: { ok: true, sent: true } }; // honeypot: bots fill it, humans never see it
|
||||
// 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 } }; }
|
||||
const fts = Number(b.fts) || 0;
|
||||
if (!fts || now - fts < CODE_LIMITS.minFormMs || now - fts > 12 * 3600 * 1000) 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.' } }; }
|
||||
const minute = Math.floor(now / 60000);
|
||||
if (codeGlobal.minute !== minute) { codeGlobal.minute = minute; codeGlobal.n = 0; }
|
||||
if (codeGlobal.n >= CODE_LIMITS.globalPerMin) { codeTrip(req, 'global'); return { status: 429, body: { error: 'Busy right now. Try again in a minute.' } }; }
|
||||
if (codeGlobal.n >= CODE_LIMITS.globalPerMin) { guardLog(req, 'global-limit', b); codeTrip(req, 'global'); return { status: 429, body: { error: 'Busy right now. Try again in a minute.' } }; }
|
||||
const ip = clientIp(req);
|
||||
const rec = codeHits.get(ip) || { t: [], passUntil: 0, chal: null };
|
||||
rec.t = rec.t.filter(ts => now - ts < 24 * 3600 * 1000);
|
||||
@@ -150,7 +154,7 @@ function codeGuard(req, b) {
|
||||
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 {
|
||||
codeTrip(req, ip);
|
||||
guardLog(req, pick ? 'wrong-pick' : 'ip-limit', b); 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 } };
|
||||
}
|
||||
@@ -722,7 +726,7 @@ const server = http.createServer(async (req, res) => {
|
||||
const e = String(b.email || '').trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
|
||||
const prev = emailCodes.get(e);
|
||||
if (prev && Date.now() < prev.nextAt) return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' });
|
||||
if (prev && Date.now() < prev.nextAt) { console.log('signup-guard cooldown', clientIp(req), e.replace(/^(.).*(@.*)$/, '$1***$2')); return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' }); }
|
||||
const guard = codeGuard(req, b); // honeypot, form age, per-IP + global limits, icon check once limited
|
||||
if (guard) return json(res, guard.status, guard.body);
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
|
||||
Reference in New Issue
Block a user