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>
This commit is contained in:
2026-07-23 07:13:14 -04:00
parent 50fdbb1b1d
commit 04ef642775
6 changed files with 383 additions and 8 deletions

View File

@@ -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,9 +109,22 @@ 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 ?? {};
@@ -193,9 +208,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 {
@@ -207,6 +224,11 @@ app.post('/api/calculate', async (req, res) => {
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
}
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;