Validate email addresses on both public form endpoints
The ROI calculator accepted the email field with no validation at all and stored whatever arrived, which is where the spam submissions were putting shell-command payloads. Nothing was executable — there is no child_process or eval in the codebase and all writes are parameterized — but the junk was being persisted, and the field is the obvious place to stop it. Adds security.validateEmail(), deliberately stricter than RFC 5322: the local part is limited to the characters real addresses use, which excludes every shell metacharacter (; | & ` $ ( ) < > \ " ' space) and CSV-injection lead-ins. Also rejects control characters (including the CR/LF used for mail-header injection), caps lengths at 254/64/253, rejects non-strings, and normalizes to trimmed lowercase before storage. Applied to /api/calculate (optional field — empty is fine, present must be valid) and to /api/leads, replacing its much weaker regex. The client mirrors the check for immediate feedback; the server remains authoritative. Also hardens the /api/leads required-field checks, which called .trim() on unvalidated input and returned a 500 rather than a 400 when a bot posted a non-string. Trade-off: RFC-legal but vanishingly rare addresses (foo!bar$baz@x.com, a leading + in the local part) are rejected. Those characters are the injection surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
42
server.js
42
server.js
@@ -129,36 +129,41 @@ app.get('/api/form-token', (_req, res) => {
|
||||
app.post('/api/leads', (req, res) => {
|
||||
const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {};
|
||||
|
||||
// Coerce defensively: a non-string (array, object, number) would otherwise
|
||||
// blow up on .trim() and surface as a 500 instead of a 400.
|
||||
const str = v => (typeof v === 'string' ? v.trim() : '');
|
||||
|
||||
// Validate required fields
|
||||
if (!firstName?.trim() || !lastName?.trim() || !email?.trim()) {
|
||||
if (!str(firstName) || !str(lastName) || !str(email)) {
|
||||
return res.status(400).json({ error: 'firstName, lastName, and email are required.' });
|
||||
}
|
||||
if (!orgName?.trim()) {
|
||||
if (!str(orgName)) {
|
||||
return res.status(400).json({ error: 'Organization name is required.' });
|
||||
}
|
||||
if (!state?.trim()) {
|
||||
if (!str(state)) {
|
||||
return res.status(400).json({ error: 'State is required.' });
|
||||
}
|
||||
|
||||
// Simple email format check
|
||||
const emailRx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRx.test(email.trim())) {
|
||||
return res.status(400).json({ error: 'Invalid email address.' });
|
||||
// Strict email format check — see security.validateEmail
|
||||
const emailCheck = security.validateEmail(email);
|
||||
if (!emailCheck.ok) {
|
||||
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
|
||||
}
|
||||
const cleanEmail = emailCheck.email;
|
||||
|
||||
// Check for duplicate
|
||||
const existing = findByEmail.get(email.trim().toLowerCase());
|
||||
const existing = findByEmail.get(cleanEmail);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'This email is already on the list.', id: existing.id });
|
||||
}
|
||||
|
||||
try {
|
||||
const info = insertLead.run({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
email: email.trim().toLowerCase(),
|
||||
orgName: orgName?.trim() ?? null,
|
||||
state: state?.trim() ?? null,
|
||||
firstName: str(firstName),
|
||||
lastName: str(lastName),
|
||||
email: cleanEmail,
|
||||
orgName: str(orgName) || null,
|
||||
state: str(state) || null,
|
||||
role: role ?? null,
|
||||
unitCount: unitCount ?? null,
|
||||
betaInterest: betaInterest ? 1 : 0,
|
||||
@@ -190,7 +195,7 @@ app.post('/api/calculate', async (req, res) => {
|
||||
function saveCalcSubmission(aiRecommendation) {
|
||||
try {
|
||||
insertCalcSubmission.run({
|
||||
email: email?.trim() || null,
|
||||
email: cleanEmail,
|
||||
optIn: optIn ? 1 : 0,
|
||||
homesites: homesites || null,
|
||||
propertyType: propertyType || null,
|
||||
@@ -220,6 +225,15 @@ app.post('/api/calculate', async (req, res) => {
|
||||
email, optIn, totalPotential, opInterest, resInterest,
|
||||
} = req.body ?? {};
|
||||
|
||||
// Email is optional here, but if one is supplied it must be a real address.
|
||||
// Nothing is stored or sent onward until it passes.
|
||||
const emailCheck = security.validateEmail(email, { required: false });
|
||||
if (!emailCheck.ok) {
|
||||
console.warn(`[abuse] rejected email from ${guard.ip}: ${JSON.stringify(String(email).slice(0, 120))}`);
|
||||
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
|
||||
}
|
||||
const cleanEmail = emailCheck.email;
|
||||
|
||||
if (!homesites || !annualIncome) {
|
||||
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user