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:
21
app.js
21
app.js
@@ -121,6 +121,16 @@
|
||||
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
|
||||
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
|
||||
|
||||
// Mirrors security.js validateEmail — the server is the authority, this just
|
||||
// gives immediate feedback instead of a round-trip.
|
||||
const EMAIL_RX = /^[A-Za-z0-9](?:[A-Za-z0-9._%+-]{0,62}[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,24}$/;
|
||||
|
||||
function isValidEmail(v) {
|
||||
if (v.length > 254 || v.indexOf('@') > 64) return false;
|
||||
if (v.includes('..')) return false;
|
||||
return EMAIL_RX.test(v);
|
||||
}
|
||||
|
||||
function showCalcError(msg) {
|
||||
if (!calcErr) return;
|
||||
calcErr.textContent = msg;
|
||||
@@ -148,6 +158,14 @@
|
||||
showCalcError('Please fill in homesites and annual dues income to continue.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Email is optional, but anything entered must be a real address.
|
||||
if (calcEmail && !isValidEmail(calcEmail)) {
|
||||
showCalcError('Please enter a valid email address.');
|
||||
document.getElementById('calcEmail')?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
calcErr.classList.add('hidden');
|
||||
setCalcLoading(true);
|
||||
|
||||
@@ -225,9 +243,10 @@
|
||||
// is stored. AI/service errors (502/503) fall through to the local result.
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.blocked) {
|
||||
if (data.blocked || data.field === 'email') {
|
||||
setCalcLoading(false);
|
||||
showCalcError(data.error || 'We could not verify this submission. Please try again.');
|
||||
if (data.field === 'email') document.getElementById('calcEmail')?.focus();
|
||||
resetCaptcha();
|
||||
await fetchFormToken(); // tokens are single-use; issue a fresh one
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user