Add abuse protection to the ROI calculator form
The calculator endpoint was open to anyone, and it has been collecting spam submissions. Add a layered guard that runs before anything is written to the DB or sent to the AI provider: - Cloudflare Turnstile CAPTCHA, verified server-side (active when TURNSTILE_* keys are configured; fails closed if unverifiable) - Honeypot field that only bots fill in - Signed, single-use form token enforcing a 4s minimum fill time - Per-IP rate limiting (5 per 10 min, 25 per 24h) Layers 2-4 need no configuration and work on their own, so submissions are throttled immediately; adding Turnstile keys upgrades it to a full challenge. Blocked submissions now stop the flow client-side instead of silently showing a result. Also sets `trust proxy` for correct client IPs behind nginx, caps the JSON body at 32kb, and fixes a latent ReferenceError in the "AI not configured" branch that called saveCalcSubmission() before the variables it closes over were declared. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
103
app.js
103
app.js
@@ -34,10 +34,76 @@
|
||||
|
||||
if (!overlay) return;
|
||||
|
||||
function open() { overlay.classList.add('open'); document.body.style.overflow = 'hidden'; }
|
||||
// ── Abuse protection ───────────────────────────────────
|
||||
// A signed, single-use token is fetched when the form is opened; the server
|
||||
// uses it to prove the form was really loaded and that a human took at least a
|
||||
// few seconds to fill it in. When Turnstile keys are configured server-side, a
|
||||
// CAPTCHA widget is rendered too.
|
||||
const captchaSlot = document.getElementById('calcCaptcha');
|
||||
let formToken = null;
|
||||
let captchaWidget = null;
|
||||
let siteKeyPromise = null;
|
||||
|
||||
async function fetchFormToken() {
|
||||
try {
|
||||
const res = await fetch('/api/form-token', { cache: 'no-store' });
|
||||
formToken = (await res.json()).token || null;
|
||||
} catch (_) { formToken = null; }
|
||||
}
|
||||
|
||||
function loadTurnstileScript() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.turnstile) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
s.async = true;
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
async function initCaptcha() {
|
||||
if (!captchaSlot || captchaWidget !== null) return;
|
||||
siteKeyPromise = siteKeyPromise || fetch('/api/form-config', { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(c => c.turnstileSiteKey)
|
||||
.catch(() => null);
|
||||
|
||||
const siteKey = await siteKeyPromise;
|
||||
if (!siteKey) return; // CAPTCHA not configured — other layers still apply
|
||||
|
||||
try {
|
||||
await loadTurnstileScript();
|
||||
captchaWidget = window.turnstile.render(captchaSlot, {
|
||||
sitekey: siteKey,
|
||||
theme: 'light',
|
||||
action: 'roi_calculator',
|
||||
});
|
||||
} catch (_) { /* widget unavailable — server decides whether to allow */ }
|
||||
}
|
||||
|
||||
function captchaResponse() {
|
||||
if (captchaWidget === null || !window.turnstile) return '';
|
||||
return window.turnstile.getResponse(captchaWidget) || '';
|
||||
}
|
||||
|
||||
function resetCaptcha() {
|
||||
if (captchaWidget !== null && window.turnstile) window.turnstile.reset(captchaWidget);
|
||||
}
|
||||
|
||||
function open() {
|
||||
overlay.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
fetchFormToken(); // starts the minimum-fill-time clock
|
||||
initCaptcha();
|
||||
}
|
||||
function close() { overlay.classList.remove('open'); document.body.style.overflow = ''; }
|
||||
|
||||
openBtn?.addEventListener('click', open);
|
||||
// Footer CTA also opens the modal (v2.js handles its analytics) — it needs the
|
||||
// same form token and CAPTCHA set-up.
|
||||
document.getElementById('openCalc2')?.addEventListener('click', open);
|
||||
closeBtn?.addEventListener('click', close);
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
||||
@@ -55,6 +121,12 @@
|
||||
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
|
||||
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
|
||||
|
||||
function showCalcError(msg) {
|
||||
if (!calcErr) return;
|
||||
calcErr.textContent = msg;
|
||||
calcErr.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function setCalcLoading(on) {
|
||||
if (!submitBtn) return;
|
||||
submitBtn.disabled = on;
|
||||
@@ -73,12 +145,19 @@
|
||||
const calcOptIn = document.getElementById('calcOptIn')?.checked ?? true;
|
||||
|
||||
if (!homesites || !annualIncome) {
|
||||
calcErr.classList.remove('hidden');
|
||||
showCalcError('Please fill in homesites and annual dues income to continue.');
|
||||
return;
|
||||
}
|
||||
calcErr.classList.add('hidden');
|
||||
setCalcLoading(true);
|
||||
|
||||
// ── Abuse checks: server verifies the token, honeypot and CAPTCHA ──
|
||||
const guardBody = {
|
||||
formToken,
|
||||
captchaToken: captchaResponse(),
|
||||
hp_company_url: document.getElementById('hpCompanyUrl')?.value || '',
|
||||
};
|
||||
|
||||
// ── Conservative investment assumptions ──
|
||||
// Operating cash: depending on payment frequency, portion investable in high-yield savings
|
||||
const opMultiplier = { monthly: 0.10, quarterly: 0.20, annually: 0.35 }[paymentFreq] || 0.10;
|
||||
@@ -131,17 +210,35 @@
|
||||
|
||||
// ── AI recommendation — call server to generate & save to DB (not displayed) ──
|
||||
try {
|
||||
await fetch('/api/calculate', {
|
||||
const res = await fetch('/api/calculate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...guardBody,
|
||||
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
|
||||
email: calcEmail, optIn: calcOptIn,
|
||||
totalPotential, opInterest, resInterest,
|
||||
}),
|
||||
});
|
||||
|
||||
// A blocked submission stops here — the estimate is not shown and nothing
|
||||
// is stored. AI/service errors (502/503) fall through to the local result.
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.blocked) {
|
||||
setCalcLoading(false);
|
||||
showCalcError(data.error || 'We could not verify this submission. Please try again.');
|
||||
resetCaptcha();
|
||||
await fetchFormToken(); // tokens are single-use; issue a fresh one
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_) { /* best-effort — DB save failed silently */ }
|
||||
|
||||
// Token is spent on a successful submission; get another for a recalculation.
|
||||
fetchFormToken();
|
||||
resetCaptcha();
|
||||
|
||||
// ── Animate the main number ──
|
||||
animateValue(document.getElementById('resultAmount'), 0, totalPotential);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user