Compare commits
5 Commits
50fdbb1b1d
...
feature/bl
| Author | SHA1 | Date | |
|---|---|---|---|
| 1219117adf | |||
| 5f4af3886c | |||
| 156adeab59 | |||
| c6425887f1 | |||
| 04ef642775 |
10
.env.example
10
.env.example
@@ -8,3 +8,13 @@ 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
|
||||
|
||||
# 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=
|
||||
141
app.js
141
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,34 @@
|
||||
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
|
||||
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}$/;
|
||||
|
||||
// Reserved documentation domains / TLDs — mirrors security.js. Real leads
|
||||
// never come from these; spam bots use them.
|
||||
const BLOCKED_EMAIL_DOMAINS = ['example.com', 'example.org', 'example.net', 'example.edu'];
|
||||
const BLOCKED_EMAIL_TLDS = ['test', 'example', 'invalid', 'localhost'];
|
||||
|
||||
function isValidEmail(v) {
|
||||
if (v.length > 254 || v.indexOf('@') > 64) return false;
|
||||
if (v.includes('..')) return false;
|
||||
if (!EMAIL_RX.test(v)) return false;
|
||||
const domain = v.slice(v.indexOf('@') + 1).toLowerCase();
|
||||
const tld = domain.slice(domain.lastIndexOf('.') + 1);
|
||||
if (BLOCKED_EMAIL_TLDS.includes(tld)) return false;
|
||||
if (BLOCKED_EMAIL_DOMAINS.some(d => domain === d || domain.endsWith('.' + d))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const MIN_HOMESITES = 10;
|
||||
|
||||
function showCalcError(msg) {
|
||||
if (!calcErr) return;
|
||||
calcErr.textContent = msg;
|
||||
calcErr.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function setCalcLoading(on) {
|
||||
if (!submitBtn) return;
|
||||
submitBtn.disabled = on;
|
||||
@@ -73,12 +167,33 @@
|
||||
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;
|
||||
}
|
||||
|
||||
if (homesites < MIN_HOMESITES) {
|
||||
showCalcError(`Please enter the number of homesites in your community (minimum ${MIN_HOMESITES}).`);
|
||||
document.getElementById('calcHomesites')?.focus();
|
||||
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');
|
||||
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 +246,37 @@
|
||||
|
||||
// ── 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 || data.field) {
|
||||
setCalcLoading(false);
|
||||
showCalcError(data.error || 'We could not verify this submission. Please try again.');
|
||||
if (data.field === 'email') document.getElementById('calcEmail')?.focus();
|
||||
if (data.field === 'homesites') document.getElementById('calcHomesites')?.focus();
|
||||
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);
|
||||
|
||||
|
||||
11
index.html
11
index.html
@@ -469,7 +469,7 @@
|
||||
<div class="calc-grid">
|
||||
<div class="calc-field">
|
||||
<label for="calcHomesites">Number of homesites</label>
|
||||
<input type="number" id="calcHomesites" placeholder="e.g. 150" min="1" />
|
||||
<input type="number" id="calcHomesites" placeholder="e.g. 150" min="10" />
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label for="calcPropertyType">Property type</label>
|
||||
@@ -509,8 +509,17 @@
|
||||
</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>
|
||||
|
||||
<!-- CAPTCHA widget — rendered only when Turnstile keys are configured -->
|
||||
<div class="calc-captcha" id="calcCaptcha"></div>
|
||||
|
||||
<div class="calc-email-row">
|
||||
<div class="calc-field calc-field--full">
|
||||
<label for="calcEmail">Your email address <span class="calc-optional">(recommended)</span></label>
|
||||
|
||||
324
security.js
Normal file
324
security.js
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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.',
|
||||
email_required: 'Please enter your email address.',
|
||||
email_invalid: 'Please enter a valid email address.',
|
||||
email_too_long: 'That email address is too long.',
|
||||
email_blocked: 'Please use a valid work or personal email address.',
|
||||
homesites_too_low: 'Please enter the number of homesites in your community (minimum 10).',
|
||||
};
|
||||
|
||||
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]/;
|
||||
|
||||
// Domains that are never a real lead. These are the IANA reserved documentation
|
||||
// domains (RFC 2606) plus reserved TLDs, which is what spam bots reach for.
|
||||
// A submitted domain is blocked when it equals one of these or is a subdomain
|
||||
// of one (e.g. `mail.example.com`).
|
||||
const BLOCKED_EMAIL_DOMAINS = new Set([
|
||||
'example.com', 'example.org', 'example.net', 'example.edu',
|
||||
]);
|
||||
const BLOCKED_EMAIL_TLDS = new Set(['test', 'example', 'invalid', 'localhost']);
|
||||
|
||||
function isBlockedDomain(domain) {
|
||||
const d = domain.toLowerCase();
|
||||
const tld = d.slice(d.lastIndexOf('.') + 1);
|
||||
if (BLOCKED_EMAIL_TLDS.has(tld)) return true;
|
||||
|
||||
for (const blocked of BLOCKED_EMAIL_DOMAINS) {
|
||||
if (d === blocked || d.endsWith('.' + blocked)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' };
|
||||
if (isBlockedDomain(domain)) return { ok: false, reason: 'email_blocked' };
|
||||
|
||||
return { ok: true, email: email.toLowerCase() };
|
||||
}
|
||||
|
||||
// ── Homesites validation ─────────────────────────────────
|
||||
// Real associations have at least this many units; smaller values are the
|
||||
// placeholder junk (0, 1, 2…) the spam submissions use.
|
||||
const MIN_HOMESITES = 10;
|
||||
|
||||
/**
|
||||
* Validate a homesites count.
|
||||
* Returns { ok: true, homesites } (a finite number) or { ok: false, reason }.
|
||||
*/
|
||||
function validateHomesites(raw) {
|
||||
const n = typeof raw === 'number' ? raw : parseFloat(raw);
|
||||
if (!Number.isFinite(n) || n < MIN_HOMESITES) {
|
||||
return { ok: false, reason: 'homesites_too_low' };
|
||||
}
|
||||
return { ok: true, homesites: n };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
validateHomesites,
|
||||
MIN_HOMESITES,
|
||||
messageFor,
|
||||
publicConfig,
|
||||
clientIp,
|
||||
};
|
||||
81
server.js
81
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,43 +109,61 @@ 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 ?? {};
|
||||
|
||||
// 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
|
||||
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.' });
|
||||
}
|
||||
if (!orgName?.trim()) {
|
||||
if (!str(orgName)) {
|
||||
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.' });
|
||||
}
|
||||
|
||||
// Simple email format check
|
||||
const emailRx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRx.test(email.trim())) {
|
||||
return res.status(400).json({ error: 'Invalid email address.' });
|
||||
// Strict email format check — see security.validateEmail
|
||||
const emailCheck = security.validateEmail(email);
|
||||
if (!emailCheck.ok) {
|
||||
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
|
||||
}
|
||||
const cleanEmail = emailCheck.email;
|
||||
|
||||
// Check for duplicate
|
||||
const existing = findByEmail.get(email.trim().toLowerCase());
|
||||
const existing = findByEmail.get(cleanEmail);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'This email is already on the list.', id: existing.id });
|
||||
}
|
||||
|
||||
try {
|
||||
const info = insertLead.run({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
email: email.trim().toLowerCase(),
|
||||
orgName: orgName?.trim() ?? null,
|
||||
state: state?.trim() ?? null,
|
||||
firstName: str(firstName),
|
||||
lastName: str(lastName),
|
||||
email: cleanEmail,
|
||||
orgName: str(orgName) || null,
|
||||
state: str(state) || null,
|
||||
role: role ?? null,
|
||||
unitCount: unitCount ?? null,
|
||||
betaInterest: betaInterest ? 1 : 0,
|
||||
@@ -175,7 +195,7 @@ app.post('/api/calculate', async (req, res) => {
|
||||
function saveCalcSubmission(aiRecommendation) {
|
||||
try {
|
||||
insertCalcSubmission.run({
|
||||
email: email?.trim() || null,
|
||||
email: cleanEmail,
|
||||
optIn: optIn ? 1 : 0,
|
||||
homesites: homesites || null,
|
||||
propertyType: propertyType || null,
|
||||
@@ -193,9 +213,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 {
|
||||
@@ -203,10 +225,31 @@ app.post('/api/calculate', async (req, res) => {
|
||||
email, optIn, totalPotential, opInterest, resInterest,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (!homesites || !annualIncome) {
|
||||
// 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 (!annualIncome) {
|
||||
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
||||
}
|
||||
|
||||
// Homesites must be a real community size; tiny/placeholder values are spam.
|
||||
const homesitesCheck = security.validateHomesites(homesites);
|
||||
if (!homesitesCheck.ok) {
|
||||
console.warn(`[abuse] rejected homesites from ${guard.ip}: ${JSON.stringify(homesites)}`);
|
||||
return res.status(400).json({ error: security.messageFor(homesitesCheck.reason), field: 'homesites' });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
17
styles.css
17
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;
|
||||
|
||||
Reference in New Issue
Block a user