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 @@
+
+
Please fill in homesites and annual dues income to continue.
+
+
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;