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:
30
server.js
30
server.js
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user