1 Commits

Author SHA1 Message Date
04ef642775 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>
2026-07-23 07:13:14 -04:00
6 changed files with 383 additions and 8 deletions

View File

@@ -8,3 +8,13 @@ AI_API_KEY=your_nvidia_api_key_here
AI_MODEL=qwen/qwen3.5-397b-a17b AI_MODEL=qwen/qwen3.5-397b-a17b
# Set to 'true' to enable detailed AI prompt/response logging # Set to 'true' to enable detailed AI prompt/response logging
AI_DEBUG=false 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=

103
app.js
View File

@@ -34,10 +34,76 @@
if (!overlay) return; 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 = ''; } function close() { overlay.classList.remove('open'); document.body.style.overflow = ''; }
openBtn?.addEventListener('click', open); 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); closeBtn?.addEventListener('click', close);
overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
@@ -55,6 +121,12 @@
const calcBtnText = submitBtn?.querySelector('.calc-btn-text'); const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading'); const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
function showCalcError(msg) {
if (!calcErr) return;
calcErr.textContent = msg;
calcErr.classList.remove('hidden');
}
function setCalcLoading(on) { function setCalcLoading(on) {
if (!submitBtn) return; if (!submitBtn) return;
submitBtn.disabled = on; submitBtn.disabled = on;
@@ -73,12 +145,19 @@
const calcOptIn = document.getElementById('calcOptIn')?.checked ?? true; const calcOptIn = document.getElementById('calcOptIn')?.checked ?? true;
if (!homesites || !annualIncome) { if (!homesites || !annualIncome) {
calcErr.classList.remove('hidden'); showCalcError('Please fill in homesites and annual dues income to continue.');
return; return;
} }
calcErr.classList.add('hidden'); calcErr.classList.add('hidden');
setCalcLoading(true); 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 ── // ── Conservative investment assumptions ──
// Operating cash: depending on payment frequency, portion investable in high-yield savings // 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; 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) ── // ── AI recommendation — call server to generate & save to DB (not displayed) ──
try { try {
await fetch('/api/calculate', { const res = await fetch('/api/calculate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
...guardBody,
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025, homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
email: calcEmail, optIn: calcOptIn, email: calcEmail, optIn: calcOptIn,
totalPotential, opInterest, resInterest, 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 */ } } 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 ── // ── Animate the main number ──
animateValue(document.getElementById('resultAmount'), 0, totalPotential); animateValue(document.getElementById('resultAmount'), 0, totalPotential);

View File

@@ -509,8 +509,17 @@
</div> </div>
</div> </div>
<!-- Honeypot: hidden from humans, irresistible to bots. Leave it empty. -->
<div class="calc-hp" aria-hidden="true">
<label for="hpCompanyUrl">Company website</label>
<input type="text" id="hpCompanyUrl" name="hp_company_url" tabindex="-1" autocomplete="off" />
</div>
<p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p> <p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p>
<!-- CAPTCHA widget — rendered only when Turnstile keys are configured -->
<div class="calc-captcha" id="calcCaptcha"></div>
<div class="calc-email-row"> <div class="calc-email-row">
<div class="calc-field calc-field--full"> <div class="calc-field calc-field--full">
<label for="calcEmail">Your email address <span class="calc-optional">(recommended)</span></label> <label for="calcEmail">Your email address <span class="calc-optional">(recommended)</span></label>

220
security.js Normal file
View File

@@ -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 24 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,
};

View File

@@ -16,6 +16,8 @@ const express = require('express');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const OpenAI = require('openai'); const OpenAI = require('openai');
const security = require('./security');
// ── Config ────────────────────────────────────────────── // ── Config ──────────────────────────────────────────────
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -107,9 +109,22 @@ const getAllLeads = db.prepare(`
// ── App ─────────────────────────────────────────────────── // ── App ───────────────────────────────────────────────────
const app = express(); 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 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 // POST /api/leads — capture a new preview sign-up
app.post('/api/leads', (req, res) => { app.post('/api/leads', (req, res) => {
const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {}; 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) { // ── Abuse protection: CAPTCHA, honeypot, form token, rate limit ──
saveCalcSubmission(null); // Runs before anything is written to the DB or sent to the AI provider.
return res.status(503).json({ error: 'AI service not configured.' }); const guard = await security.guardSubmission(req);
if (!guard.ok) {
return res.status(guard.status).json({ error: guard.error, blocked: true });
} }
const { const {
@@ -207,6 +224,11 @@ app.post('/api/calculate', async (req, res) => {
return res.status(400).json({ error: 'homesites and annualIncome are required.' }); 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 fmt = n => '$' + Math.round(n).toLocaleString();
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || ''; const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12; const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12;

View File

@@ -821,6 +821,23 @@ a.feature-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg)
} }
.input-prefix-wrap input { padding-left: 28px; } .input-prefix-wrap input { padding-left: 28px; }
.calc-error { color: var(--red); font-size: 13px; margin: 8px 0; font-weight: 500; } .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-submit-btn { width: 100%; justify-content: center; margin-top: 16px; }
.calc-fine { .calc-fine {
font-size: 11px; font-size: 11px;