From 04ef6427755e4a0b983eed927ab52626c70bdb4e Mon Sep 17 00:00:00 2001 From: olsch01 Date: Thu, 23 Jul 2026 07:13:14 -0400 Subject: [PATCH] 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 --- .env.example | 12 ++- app.js | 103 +++++++++++++++++++++++- index.html | 9 +++ security.js | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 30 ++++++- styles.css | 17 ++++ 6 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 security.js diff --git a/.env.example b/.env.example index 428564a..5beeebf 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,14 @@ AI_API_URL=https://integrate.api.nvidia.com/v1 AI_API_KEY=your_nvidia_api_key_here AI_MODEL=qwen/qwen3.5-397b-a17b # Set to 'true' to enable detailed AI prompt/response logging -AI_DEBUG=false \ No newline at end of file +AI_DEBUG=false + +# Form abuse protection +# Cloudflare Turnstile (free): https://dash.cloudflare.com/?to=/:account/turnstile +# Leave blank to run without a visible CAPTCHA — the honeypot, signed form token +# and per-IP rate limits stay active either way. +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= +# Optional: stable secret for signing form tokens. If unset, a random one is +# generated per process (tokens simply stop validating across restarts). +FORM_TOKEN_SECRET= \ No newline at end of file diff --git a/app.js b/app.js index 2cec577..85cd4a3 100644 --- a/app.js +++ b/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); diff --git a/index.html b/index.html index c0e1f76..430aa9a 100644 --- a/index.html +++ b/index.html @@ -509,8 +509,17 @@ + + + + +
+