Files
HOALedgerIQ_Website/security.js
olsch01 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

221 lines
8.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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