Sign-up code guard: honeypot, form age, per-IP + global limits, progressive icon check, burst alert

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-11 06:30:03 -05:00
parent f9b198a49b
commit 159e19dcdf
7 changed files with 107 additions and 11 deletions
+63 -1
View File
@@ -116,6 +116,59 @@ function chatLimited(ip) {
}
// magic-code sign-in: emailLower -> {code, exp, tries}
const emailCodes = new Map();
// ── sign-up code guard (Marty, 2026-09-11): the email box is one field and one
// tap, so nothing visible stands in a human's way. Bots hit four invisible walls:
// a honeypot field, a minimum form age, per-IP + global send limits, and, only
// once an IP trips a limit, the same icon check the ad viewer uses.
const CODE_LIMITS = { per10m: 5, perDay: 20, globalPerMin: 60, passMs: 5 * 60 * 1000, minFormMs: 2000 };
const codeHits = new Map(); // ip -> { t: [send timestamps, 24h], passUntil, chal: { answer, exp } }
const codeGlobal = { minute: 0, n: 0 };
const codeAlert = { last: 0, trips: 0, ips: new Set() };
function clientIp(req) { return String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim() || 'unknown'; }
function codeChallenge(rec) {
const pick = CAPTCHA.slice().sort(() => Math.random() - 0.5).slice(0, 5);
const answer = Math.floor(Math.random() * pick.length);
rec.chal = { answer: pick[answer][0], exp: Date.now() + 5 * 60 * 1000 };
return { prompt: pick[answer][1], options: pick.map(x => x[0]) };
}
// returns null to allow the send, or { status, body } to answer with instead
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
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.' } };
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.' } }; }
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);
const n10 = rec.t.filter(ts => now - ts < 10 * 60 * 1000).length;
const limited = 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 {
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 } };
}
}
rec.t.push(now); codeHits.set(ip, rec); codeGlobal.n += 1;
if (codeHits.size > 5000) for (const [k, v] of codeHits) { if (!v.t.length || now - v.t[v.t.length - 1] > 24 * 3600 * 1000) codeHits.delete(k); }
return null;
}
// burst alert: at most one message per 10 minutes, to the admin Telegram chat if set, else the admin email
function codeTrip(req, ip) {
codeAlert.trips += 1; codeAlert.ips.add(ip);
if (Date.now() - codeAlert.last < 10 * 60 * 1000) return;
codeAlert.last = Date.now();
const text = '\u26A0\uFE0F InstantAdPay sign-up guard: ' + codeAlert.trips + ' blocked code request' + (codeAlert.trips === 1 ? '' : 's') + ' from ' + codeAlert.ips.size + ' source' + (codeAlert.ips.size === 1 ? '' : 's') + ' (' + [...codeAlert.ips].slice(0, 5).join(', ') + ') in the last window.';
codeAlert.trips = 0; codeAlert.ips = new Set();
const sc = siteConfig();
if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {});
else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'InstantAdPay: sign-up guard tripped', text).catch(() => {});
}
// earn-view tokens: emailLower -> {token, ts} (one live token per member)
const earnTokens = new Map();
// human-check pairs for the view verifier: [emoji shown, word named in the prompt]
@@ -272,6 +325,7 @@ function siteConfig() {
rehearsal: true, // shows the testnet banner; flipped off at mainnet launch
// payment-proof Telegram feed (blank = off) and the P&L pane's fixed monthly cost
telegramBotToken: '', telegramChatId: '', telegramTopicId: '', telegramEvents: 'payouts', telegramCtaUrl: 'https://instantadpay.com/',
telegramAdminChatId: '', // private chat for admin alerts (sign-up guard bursts); falls back to ADMIN_EMAIL
pnlFixedMonthlyUsd: 0
}, saved);
}
@@ -470,7 +524,13 @@ async function telegramOnEvent(ev) {
else if (ev.type === 'MemberActivated' && mode === 'all') line = '\u{1F91D} ' + who(ev.id) + ' switched on payouts';
if (!line) return;
const text = line + ' \u00b7 <a href="' + tx + '">verify</a>' + (sc.telegramCtaUrl ? '\n<a href="' + sc.telegramCtaUrl + '">Join free</a>' : '');
const body = JSON.stringify(Object.assign({ chat_id: sc.telegramChatId, text, parse_mode: 'HTML', disable_web_page_preview: true }, sc.telegramTopicId ? { message_thread_id: Number(sc.telegramTopicId) } : {}));
await telegramSend(sc.telegramChatId, text, sc.telegramTopicId);
}
// one sendMessage call; never throws, never logs the token
async function telegramSend(chatId, text, threadId) {
const sc = siteConfig();
if (!sc.telegramBotToken || !chatId) return;
const body = JSON.stringify(Object.assign({ chat_id: chatId, text, parse_mode: 'HTML', disable_web_page_preview: true }, threadId ? { message_thread_id: Number(threadId) } : {}));
await new Promise((resolve) => {
const rq = https.request({ hostname: 'api.telegram.org', path: '/bot' + sc.telegramBotToken + '/sendMessage', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 10000 }, r => { r.resume(); r.on('end', resolve); });
rq.on('error', () => resolve()); rq.on('timeout', () => { rq.destroy(); resolve(); }); rq.end(body);
@@ -658,6 +718,8 @@ const server = http.createServer(async (req, res) => {
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.' });
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));
emailCodes.set(e, { code, exp: Date.now() + 15 * 60 * 1000, tries: 0, nextAt: Date.now() + 60 * 1000 });
if (mailer.hasKey()) {