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>
221 lines
8.5 KiB
JavaScript
221 lines
8.5 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.',
|
||
};
|
||
|
||
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,
|
||
};
|