/** * 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, };