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 @@ + + + + +
+
diff --git a/security.js b/security.js new file mode 100644 index 0000000..a101f02 --- /dev/null +++ b/security.js @@ -0,0 +1,220 @@ +/** + * HOA LedgerIQ — Form abuse protection + * + * Layered defence for public form endpoints (ROI calculator, lead capture): + * + * 1. Cloudflare Turnstile — real CAPTCHA, active when TURNSTILE_* keys are set. + * 2. Honeypot field — a hidden input humans never fill in. + * 3. Signed form token — proves the form was actually loaded, and enforces a + * minimum fill time (bots submit instantly). + * 4. Per-IP rate limiting — caps bursts and daily volume from one source. + * + * Layers 2–4 need no configuration and work on their own; adding Turnstile keys + * upgrades the protection to a full CAPTCHA challenge. + */ + +'use strict'; + +const crypto = require('crypto'); + +// ── Config ─────────────────────────────────────────────── +const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || ''; +const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET_KEY || ''; +const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + +// If no explicit secret is configured, generate one per process. Tokens then stop +// validating across restarts — harmless, the client just fetches a fresh one. +const FORM_SECRET = process.env.FORM_TOKEN_SECRET || crypto.randomBytes(32).toString('hex'); + +const MIN_FILL_MS = 4 * 1000; // faster than this is not a human +const MAX_TOKEN_AGE_MS = 2 * 60 * 60 * 1000; // tokens expire after 2 hours + +// Sliding-window caps per IP: short burst window + daily ceiling. +const RATE_WINDOWS = [ + { windowMs: 10 * 60 * 1000, max: 5, label: '10 minutes' }, + { windowMs: 24 * 60 * 60 * 1000, max: 25, label: '24 hours' }, +]; + +const turnstileEnabled = Boolean(TURNSTILE_SITE_KEY && TURNSTILE_SECRET); + +// ── Signed, single-use form tokens ─────────────────────── +const usedTokens = new Map(); // token -> expiry ms +const hits = new Map(); // ip -> [timestamps] + +function sign(payload) { + return crypto.createHmac('sha256', FORM_SECRET).update(payload).digest('hex').slice(0, 32); +} + +function issueFormToken() { + const payload = `${Date.now()}.${crypto.randomBytes(9).toString('base64url')}`; + return `${payload}.${sign(payload)}`; +} + +function verifyFormToken(token) { + if (typeof token !== 'string' || token.length > 200) { + return { ok: false, reason: 'missing_token' }; + } + + const parts = token.split('.'); + if (parts.length !== 3) return { ok: false, reason: 'bad_token' }; + + const [tsRaw, nonce, sig] = parts; + const expected = sign(`${tsRaw}.${nonce}`); + if (sig.length !== expected.length || + !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { + return { ok: false, reason: 'bad_token' }; + } + + const issuedAt = Number(tsRaw); + if (!Number.isFinite(issuedAt)) return { ok: false, reason: 'bad_token' }; + + const age = Date.now() - issuedAt; + if (age > MAX_TOKEN_AGE_MS || age < -60_000) return { ok: false, reason: 'expired_token' }; + if (age < MIN_FILL_MS) return { ok: false, reason: 'too_fast' }; + if (usedTokens.has(token)) return { ok: false, reason: 'replayed_token' }; + + usedTokens.set(token, Date.now() + MAX_TOKEN_AGE_MS); + return { ok: true }; +} + +// ── Rate limiting ──────────────────────────────────────── +function clientIp(req) { + // Requires `app.set('trust proxy', ...)` when running behind nginx. + return req.ip || req.socket?.remoteAddress || 'unknown'; +} + +/** Check the caps without consuming a slot. */ +function checkRateLimit(ip) { + const now = Date.now(); + const list = hits.get(ip) || []; + + for (const { windowMs, max, label } of RATE_WINDOWS) { + const recent = list.filter(t => now - t < windowMs).length; + if (recent >= max) return { ok: false, reason: 'rate_limited', label }; + } + return { ok: true }; +} + +/** Record a successful submission against the caller's IP. */ +function recordSubmission(ip) { + const now = Date.now(); + const widest = Math.max(...RATE_WINDOWS.map(w => w.windowMs)); + const list = (hits.get(ip) || []).filter(t => now - t < widest); + list.push(now); + hits.set(ip, list); +} + +// ── Turnstile ──────────────────────────────────────────── +async function verifyTurnstile(token, ip) { + if (!turnstileEnabled) return { ok: true, skipped: true }; + if (typeof token !== 'string' || !token) return { ok: false, reason: 'captcha_missing' }; + + try { + const body = new URLSearchParams({ secret: TURNSTILE_SECRET, response: token }); + if (ip && ip !== 'unknown') body.set('remoteip', ip); + + const resp = await fetch(TURNSTILE_VERIFY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal: AbortSignal.timeout(8000), + }); + const data = await resp.json(); + + if (!data.success) { + return { ok: false, reason: 'captcha_failed', codes: data['error-codes'] }; + } + return { ok: true }; + } catch (err) { + console.error('Turnstile verify error:', err.message); + // Fail closed: an unverifiable challenge is not a passed challenge. + return { ok: false, reason: 'captcha_unavailable' }; + } +} + +// ── Combined guard ─────────────────────────────────────── +const MESSAGES = { + honeypot: 'Submission rejected.', + missing_token: 'Your session expired. Please reload the page and try again.', + bad_token: 'Your session expired. Please reload the page and try again.', + expired_token: 'Your session expired. Please reload the page and try again.', + replayed_token: 'This form was already submitted. Please reload the page to run another estimate.', + too_fast: 'That was a little too quick — please take a moment and try again.', + rate_limited: 'Too many submissions from this network. Please try again later.', + captcha_missing: 'Please complete the verification challenge.', + captcha_failed: 'Verification failed. Please try the challenge again.', + captcha_unavailable: 'Verification is temporarily unavailable. Please try again in a moment.', +}; + +const STATUS = { rate_limited: 429, honeypot: 400 }; + +/** + * Run every protection layer for a public form POST. + * Returns { ok: true, ip } or { ok: false, status, error, reason }. + */ +async function guardSubmission(req, { honeypotField = 'hp_company_url' } = {}) { + const ip = clientIp(req); + const body = req.body ?? {}; + + const fail = ({ reason, label }) => ({ + ok: false, + reason, + status: STATUS[reason] ?? 403, + error: label ? `${MESSAGES[reason]} (limit: ${label})` : MESSAGES[reason], + }); + + // 1. Honeypot — any value at all means a bot filled every field it found. + if (typeof body[honeypotField] === 'string' && body[honeypotField].trim() !== '') { + console.warn(`[abuse] honeypot tripped from ${ip}`); + return fail({ reason: 'honeypot' }); + } + + // 2. Rate limit (checked before the outbound Turnstile call). + const rate = checkRateLimit(ip); + if (!rate.ok) { + console.warn(`[abuse] rate limit hit by ${ip}`); + return fail(rate); + } + + // 3. Signed single-use token + minimum fill time. + const tok = verifyFormToken(body.formToken); + if (!tok.ok) { + console.warn(`[abuse] form token rejected (${tok.reason}) from ${ip}`); + return fail(tok); + } + + // 4. CAPTCHA. + const captcha = await verifyTurnstile(body.captchaToken, ip); + if (!captcha.ok) { + console.warn(`[abuse] captcha rejected (${captcha.reason}) from ${ip}`); + return fail(captcha); + } + + recordSubmission(ip); + return { ok: true, ip }; +} + +function publicConfig() { + return { turnstileSiteKey: turnstileEnabled ? TURNSTILE_SITE_KEY : null }; +} + +// ── Housekeeping ───────────────────────────────────────── +const sweep = setInterval(() => { + const now = Date.now(); + for (const [token, exp] of usedTokens) if (exp < now) usedTokens.delete(token); + + const widest = Math.max(...RATE_WINDOWS.map(w => w.windowMs)); + for (const [ip, list] of hits) { + const kept = list.filter(t => now - t < widest); + if (kept.length) hits.set(ip, kept); else hits.delete(ip); + } +}, 10 * 60 * 1000); +sweep.unref?.(); + +module.exports = { + turnstileEnabled, + issueFormToken, + guardSubmission, + publicConfig, + clientIp, +}; diff --git a/server.js b/server.js index b567922..0df0ed2 100644 --- a/server.js +++ b/server.js @@ -16,6 +16,8 @@ const express = require('express'); const Database = require('better-sqlite3'); const OpenAI = require('openai'); +const security = require('./security'); + // ── Config ────────────────────────────────────────────── const PORT = process.env.PORT || 3000; @@ -107,9 +109,22 @@ const getAllLeads = db.prepare(` // ── App ─────────────────────────────────────────────────── const app = express(); -app.use(express.json()); +app.set('trust proxy', 1); // behind nginx — needed for correct client IPs +app.use(express.json({ limit: '32kb' })); app.use(express.static(__dirname)); // serve the marketing site +// GET /api/form-config — public config the forms need (CAPTCHA site key) +app.get('/api/form-config', (_req, res) => { + res.set('Cache-Control', 'no-store'); + res.json(security.publicConfig()); +}); + +// GET /api/form-token — single-use, signed token proving the form was loaded +app.get('/api/form-token', (_req, res) => { + res.set('Cache-Control', 'no-store'); + res.json({ token: security.issueFormToken() }); +}); + // POST /api/leads — capture a new preview sign-up app.post('/api/leads', (req, res) => { const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {}; @@ -193,9 +208,11 @@ app.post('/api/calculate', async (req, res) => { } } - if (!aiClient) { - saveCalcSubmission(null); - return res.status(503).json({ error: 'AI service not configured.' }); + // ── Abuse protection: CAPTCHA, honeypot, form token, rate limit ── + // Runs before anything is written to the DB or sent to the AI provider. + const guard = await security.guardSubmission(req); + if (!guard.ok) { + return res.status(guard.status).json({ error: guard.error, blocked: true }); } const { @@ -207,6 +224,11 @@ app.post('/api/calculate', async (req, res) => { return res.status(400).json({ error: 'homesites and annualIncome are required.' }); } + if (!aiClient) { + saveCalcSubmission(null); + return res.status(503).json({ error: 'AI service not configured.' }); + } + const fmt = n => '$' + Math.round(n).toLocaleString(); const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || ''; const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12; diff --git a/styles.css b/styles.css index ba64df1..b70bb7b 100644 --- a/styles.css +++ b/styles.css @@ -821,6 +821,23 @@ a.feature-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg) } .input-prefix-wrap input { padding-left: 28px; } .calc-error { color: var(--red); font-size: 13px; margin: 8px 0; font-weight: 500; } + +/* Honeypot — visually and semantically hidden, but still fillable by bots. + Deliberately not `display:none`, which the better bots skip. */ +.calc-hp { + position: absolute !important; + left: -9999px; + top: -9999px; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; + pointer-events: none; +} + +/* CAPTCHA widget slot — collapsed until a challenge is actually rendered */ +.calc-captcha:empty { display: none; } +.calc-captcha { margin: 12px 0 0; display: flex; justify-content: center; } .calc-submit-btn { width: 100%; justify-content: center; margin-top: 16px; } .calc-fine { font-size: 11px;