Spam submissions were still getting through with placeholder data. Two more content filters on the public form endpoints: - Reject emails from reserved documentation domains (example.com/.org/ /.net/.edu and subdomains) and reserved TLDs (.test/.example/.invalid/ localhost). testing@example.com and friends are never real leads. - Reject a homesites count below 10. Real associations are larger; the junk uses 0/1/2. Both are validated server-side in security.js (validateEmail gains a domain blocklist, new validateHomesites) and mirrored client-side in app.js for immediate feedback. The homesites input min attribute goes from 1 to 10. Blocked submissions return 400 with a `field` hint and store nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
325 lines
13 KiB
JavaScript
325 lines
13 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|