3 Commits

Author SHA1 Message Date
156adeab59 Validate email addresses on both public form endpoints
The ROI calculator accepted the email field with no validation at all and
stored whatever arrived, which is where the spam submissions were putting
shell-command payloads. Nothing was executable — there is no child_process
or eval in the codebase and all writes are parameterized — but the junk was
being persisted, and the field is the obvious place to stop it.

Adds security.validateEmail(), deliberately stricter than RFC 5322: the
local part is limited to the characters real addresses use, which excludes
every shell metacharacter (; | & ` $ ( ) < > \ " ' space) and CSV-injection
lead-ins. Also rejects control characters (including the CR/LF used for
mail-header injection), caps lengths at 254/64/253, rejects non-strings,
and normalizes to trimmed lowercase before storage.

Applied to /api/calculate (optional field — empty is fine, present must be
valid) and to /api/leads, replacing its much weaker regex. The client
mirrors the check for immediate feedback; the server remains authoritative.

Also hardens the /api/leads required-field checks, which called .trim() on
unvalidated input and returned a 500 rather than a 400 when a bot posted a
non-string.

Trade-off: RFC-legal but vanishingly rare addresses (foo!bar$baz@x.com, a
leading + in the local part) are rejected. Those characters are the
injection surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:25:22 -04:00
c6425887f1 Merge pull request 'Add abuse protection to the ROI calculator form' (#24) from feature/roi-calculator-captcha into main
All checks were successful
Deploy to Production / deploy (push) Successful in 3s
2026-07-23 07:13:58 -04:00
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 492 additions and 22 deletions

View File

@@ -7,4 +7,14 @@ AI_API_URL=https://integrate.api.nvidia.com/v1
AI_API_KEY=your_nvidia_api_key_here 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=

122
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,22 @@
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');
// Mirrors security.js validateEmail — the server is the authority, this just
// gives immediate feedback instead of a round-trip.
const EMAIL_RX = /^[A-Za-z0-9](?:[A-Za-z0-9._%+-]{0,62}[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,24}$/;
function isValidEmail(v) {
if (v.length > 254 || v.indexOf('@') > 64) return false;
if (v.includes('..')) return false;
return EMAIL_RX.test(v);
}
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 +155,27 @@
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;
} }
// Email is optional, but anything entered must be a real address.
if (calcEmail && !isValidEmail(calcEmail)) {
showCalcError('Please enter a valid email address.');
document.getElementById('calcEmail')?.focus();
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 +228,36 @@
// ── 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 || data.field === 'email') {
setCalcLoading(false);
showCalcError(data.error || 'We could not verify this submission. Please try again.');
if (data.field === 'email') document.getElementById('calcEmail')?.focus();
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>

282
security.js Normal file
View File

@@ -0,0 +1,282 @@
/**
* 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.',
email_required: 'Please enter your email address.',
email_invalid: 'Please enter a valid email address.',
email_too_long: 'That email address is too long.',
};
const STATUS = { rate_limited: 429, honeypot: 400 };
/** User-facing message for a validation/abuse reason code. */
function messageFor(reason) {
return MESSAGES[reason] || 'Submission rejected.';
}
// ── Email validation ─────────────────────────────────────
// Deliberately stricter than RFC 5322. The local part is limited to the
// characters real-world addresses actually use, which excludes every shell
// metacharacter (; | & ` $ ( ) < > \ " ' space) and every CSV-injection lead-in
// (= + @ at position 0). RFC-legal oddities like `foo!bar$baz@x.com` are
// rejected — an acceptable trade for a marketing form.
const EMAIL_RX = /^[A-Za-z0-9](?:[A-Za-z0-9._%+-]{0,62}[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,24}$/;
// C0/C1 control characters and DEL — includes the CR/LF used for header injection.
const CONTROL_CHARS_RX = /[\x00-\x1F\x7F-\x9F]/;
/**
* Validate an email address.
* Returns { ok: true, email } with the normalised (trimmed, lower-cased) value,
* or { ok: false, reason }.
*
* Pass { required: false } to accept an empty value (the calculator's email
* field is optional) — an empty result comes back as { ok: true, email: null }.
*/
function validateEmail(raw, { required = true } = {}) {
if (raw === undefined || raw === null || raw === '') {
return required ? { ok: false, reason: 'email_required' } : { ok: true, email: null };
}
// Anything that isn't a plain string is a structured-injection attempt
// (arrays and objects can survive into places a string wouldn't).
if (typeof raw !== 'string') return { ok: false, reason: 'email_invalid' };
const email = raw.trim();
if (email === '') {
return required ? { ok: false, reason: 'email_required' } : { ok: true, email: null };
}
// Length caps first — bounds every check that follows.
if (email.length > 254) return { ok: false, reason: 'email_too_long' };
// Control characters, including the newlines used for header injection.
if (CONTROL_CHARS_RX.test(email)) return { ok: false, reason: 'email_invalid' };
const at = email.indexOf('@');
if (at < 1 || at !== email.lastIndexOf('@')) return { ok: false, reason: 'email_invalid' };
const local = email.slice(0, at);
const domain = email.slice(at + 1);
if (local.length > 64 || domain.length > 253) return { ok: false, reason: 'email_too_long' };
if (email.includes('..')) return { ok: false, reason: 'email_invalid' };
if (!EMAIL_RX.test(email)) return { ok: false, reason: 'email_invalid' };
return { ok: true, email: email.toLowerCase() };
}
/**
* 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,
validateEmail,
messageFor,
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,43 +109,61 @@ 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 ?? {};
// Coerce defensively: a non-string (array, object, number) would otherwise
// blow up on .trim() and surface as a 500 instead of a 400.
const str = v => (typeof v === 'string' ? v.trim() : '');
// Validate required fields // Validate required fields
if (!firstName?.trim() || !lastName?.trim() || !email?.trim()) { if (!str(firstName) || !str(lastName) || !str(email)) {
return res.status(400).json({ error: 'firstName, lastName, and email are required.' }); return res.status(400).json({ error: 'firstName, lastName, and email are required.' });
} }
if (!orgName?.trim()) { if (!str(orgName)) {
return res.status(400).json({ error: 'Organization name is required.' }); return res.status(400).json({ error: 'Organization name is required.' });
} }
if (!state?.trim()) { if (!str(state)) {
return res.status(400).json({ error: 'State is required.' }); return res.status(400).json({ error: 'State is required.' });
} }
// Simple email format check // Strict email format check — see security.validateEmail
const emailRx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailCheck = security.validateEmail(email);
if (!emailRx.test(email.trim())) { if (!emailCheck.ok) {
return res.status(400).json({ error: 'Invalid email address.' }); return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
} }
const cleanEmail = emailCheck.email;
// Check for duplicate // Check for duplicate
const existing = findByEmail.get(email.trim().toLowerCase()); const existing = findByEmail.get(cleanEmail);
if (existing) { if (existing) {
return res.status(409).json({ error: 'This email is already on the list.', id: existing.id }); return res.status(409).json({ error: 'This email is already on the list.', id: existing.id });
} }
try { try {
const info = insertLead.run({ const info = insertLead.run({
firstName: firstName.trim(), firstName: str(firstName),
lastName: lastName.trim(), lastName: str(lastName),
email: email.trim().toLowerCase(), email: cleanEmail,
orgName: orgName?.trim() ?? null, orgName: str(orgName) || null,
state: state?.trim() ?? null, state: str(state) || null,
role: role ?? null, role: role ?? null,
unitCount: unitCount ?? null, unitCount: unitCount ?? null,
betaInterest: betaInterest ? 1 : 0, betaInterest: betaInterest ? 1 : 0,
@@ -175,7 +195,7 @@ app.post('/api/calculate', async (req, res) => {
function saveCalcSubmission(aiRecommendation) { function saveCalcSubmission(aiRecommendation) {
try { try {
insertCalcSubmission.run({ insertCalcSubmission.run({
email: email?.trim() || null, email: cleanEmail,
optIn: optIn ? 1 : 0, optIn: optIn ? 1 : 0,
homesites: homesites || null, homesites: homesites || null,
propertyType: propertyType || null, propertyType: propertyType || null,
@@ -193,9 +213,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 {
@@ -203,10 +225,24 @@ app.post('/api/calculate', async (req, res) => {
email, optIn, totalPotential, opInterest, resInterest, email, optIn, totalPotential, opInterest, resInterest,
} = req.body ?? {}; } = req.body ?? {};
// Email is optional here, but if one is supplied it must be a real address.
// Nothing is stored or sent onward until it passes.
const emailCheck = security.validateEmail(email, { required: false });
if (!emailCheck.ok) {
console.warn(`[abuse] rejected email from ${guard.ip}: ${JSON.stringify(String(email).slice(0, 120))}`);
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
}
const cleanEmail = emailCheck.email;
if (!homesites || !annualIncome) { if (!homesites || !annualIncome) {
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;