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:
martbost
2026-09-11 10:30:11 -05:00
parent fbd83bf933
commit 0f855292f5
7 changed files with 17 additions and 13 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ window.IAP = (function () {
let pick = null; let pick = null;
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const r = await (await fetch('/api/auth/email/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, const r = await (await fetch('/api/auth/email/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, fts: FORM_TS, website: (o.honeypot && o.honeypot.value) || '', pick }) })).json(); body: JSON.stringify({ email, fts: FORM_TS, hp_field_x9: (o.honeypot && o.honeypot.value) || '', pick }) })).json();
if (r.challenge && o.host) { pick = await iconCheck(o.host, r.challenge, r.error); continue; } if (r.challenge && o.host) { pick = await iconCheck(o.host, r.challenge, r.error); continue; }
if (r.error) throw new Error(r.error); if (r.error) throw new Error(r.error);
return r; return r;
+2 -2
View File
@@ -111,7 +111,7 @@
<h3 id="jnCapH">Join free</h3> <h3 id="jnCapH">Join free</h3>
<p class="muted small" style="margin:0 0 12px" id="jnCapSub">Type your email and we send a 6-digit code. No password, no wallet needed today.</p> <p class="muted small" style="margin:0 0 12px" id="jnCapSub">Type your email and we send a 6-digit code. No password, no wallet needed today.</p>
<p><input id="jnEmail" type="email" placeholder="Your email" autocomplete="email"></p> <p><input id="jnEmail" type="email" placeholder="Your email" autocomplete="email"></p>
<input id="jnWebsite" class="hp-field" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true"> <input id="jnWebsite" class="hp-field" type="text" name="hp_field_x9" tabindex="-1" autocomplete="off" aria-hidden="true">
<div id="jnCheck" hidden></div> <div id="jnCheck" hidden></div>
<p id="jnCodeRow" hidden><input id="jnCode" inputmode="numeric" placeholder="6-digit code from your inbox"></p> <p id="jnCodeRow" hidden><input id="jnCode" inputmode="numeric" placeholder="6-digit code from your inbox"></p>
<p id="jnUnder" class="small muted" hidden style="margin:-4px 0 12px"></p> <p id="jnUnder" class="small muted" hidden style="margin:-4px 0 12px"></p>
@@ -155,7 +155,7 @@
InstantAdPay · <a href="/contract">Contract</a> · <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/disclaimer">Disclaimer</a> InstantAdPay · <a href="/contract">Contract</a> · <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/disclaimer">Disclaimer</a>
</div> </div>
</div> </div>
<script src="/assets/common.js?v=20260911b"></script> <script src="/assets/common.js?v=20260911c"></script>
<script src="/assets/join.js?v=20260911a"></script> <script src="/assets/join.js?v=20260911a"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -85,7 +85,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div> <div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
</footer> </footer>
</div> </div>
<script src="/assets/common.js?v=20260911b"></script> <script src="/assets/common.js?v=20260911c"></script>
<script src="/assets/launch.js?v=20260911b"></script> <script src="/assets/launch.js?v=20260911b"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -34,7 +34,7 @@
<p class="muted small">Type your email and we send a 6-digit code. No password to invent, <p class="muted small">Type your email and we send a 6-digit code. No password to invent,
no password to forget. New emails get a free account automatically.</p> no password to forget. New emails get a free account automatically.</p>
<p><input id="mcEmail" type="email" placeholder="Email" autocomplete="email" style="width:100%"></p> <p><input id="mcEmail" type="email" placeholder="Email" autocomplete="email" style="width:100%"></p>
<input id="mcWebsite" class="hp-field" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true"> <input id="mcWebsite" class="hp-field" type="text" name="hp_field_x9" tabindex="-1" autocomplete="off" aria-hidden="true">
<div id="mcCheck" hidden></div> <div id="mcCheck" hidden></div>
<p id="mcCodeRow" hidden><input id="mcCode" inputmode="numeric" placeholder="6-digit code" style="width:100%"></p> <p id="mcCodeRow" hidden><input id="mcCode" inputmode="numeric" placeholder="6-digit code" style="width:100%"></p>
<button class="btn" id="mcSendBtn">Email me a code</button> <button class="btn" id="mcSendBtn">Email me a code</button>
@@ -899,7 +899,7 @@
</div> </div>
</div> </div>
</div> </div>
<script src="/assets/common.js?v=20260911b"></script> <script src="/assets/common.js?v=20260911c"></script>
<script src="/assets/wallet.js?v=20260911a"></script> <script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260911a"></script> <script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260911v"></script> <script src="/assets/my.js?v=20260911v"></script>
+1 -1
View File
@@ -192,7 +192,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div> <div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
</footer> </footer>
</div> </div>
<script src="/assets/common.js?v=20260910c"></script> <script src="/assets/common.js?v=20260911c"></script>
<script src="/assets/plays.js?v=20260910b"></script> <script src="/assets/plays.js?v=20260910b"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -121,7 +121,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never share your recovery phrase.</div> <div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never share your recovery phrase.</div>
</footer> </footer>
</div> </div>
<script src="/assets/common.js?v=20260910c"></script> <script src="/assets/common.js?v=20260911c"></script>
<script src="/assets/wallets.js?v=20260911b"></script> <script src="/assets/wallets.js?v=20260911b"></script>
</body> </body>
</html> </html>
+9 -5
View File
@@ -133,14 +133,18 @@ function codeChallenge(rec) {
return { prompt: pick[answer][1], options: pick.map(x => x[0]) }; return { prompt: pick[answer][1], options: pick.map(x => x[0]) };
} }
// returns null to allow the send, or { status, body } to answer with instead // 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) { function codeGuard(req, b) {
const now = Date.now(); 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; 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); const minute = Math.floor(now / 60000);
if (codeGlobal.minute !== minute) { codeGlobal.minute = minute; codeGlobal.n = 0; } 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 ip = clientIp(req);
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);
@@ -150,7 +154,7 @@ function codeGuard(req, b) {
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 {
codeTrip(req, ip); guardLog(req, pick ? 'wrong-pick' : 'ip-limit', b); 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 } };
} }
@@ -722,7 +726,7 @@ const server = http.createServer(async (req, res) => {
const e = String(b.email || '').trim().toLowerCase(); 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.' }); if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
const prev = emailCodes.get(e); 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 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); if (guard) return json(res, guard.status, guard.body);
const code = String(Math.floor(100000 + Math.random() * 900000)); const code = String(Math.floor(100000 + Math.random() * 900000));