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:
2026-07-23 08:25:22 -04:00
parent c6425887f1
commit 156adeab59
3 changed files with 110 additions and 15 deletions

View File

@@ -144,10 +144,70 @@ const MESSAGES = {
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.',
email_required: 'Please enter your email address.',
email_invalid: 'Please enter a valid email address.',
email_too_long: 'That email address is too long.',
};
const STATUS = { rate_limited: 429, honeypot: 400 };
/** User-facing message for a validation/abuse reason code. */
function messageFor(reason) {
return MESSAGES[reason] || 'Submission rejected.';
}
// ── Email validation ─────────────────────────────────────
// Deliberately stricter than RFC 5322. The local part is limited to the
// characters real-world addresses actually use, which excludes every shell
// metacharacter (; | & ` $ ( ) < > \ " ' space) and every CSV-injection lead-in
// (= + @ at position 0). RFC-legal oddities like `foo!bar$baz@x.com` are
// rejected — an acceptable trade for a marketing form.
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}$/;
// C0/C1 control characters and DEL — includes the CR/LF used for header injection.
const CONTROL_CHARS_RX = /[\x00-\x1F\x7F-\x9F]/;
/**
* Validate an email address.
* Returns { ok: true, email } with the normalised (trimmed, lower-cased) value,
* or { ok: false, reason }.
*
* Pass { required: false } to accept an empty value (the calculator's email
* field is optional) — an empty result comes back as { ok: true, email: null }.
*/
function validateEmail(raw, { required = true } = {}) {
if (raw === undefined || raw === null || raw === '') {
return required ? { ok: false, reason: 'email_required' } : { ok: true, email: null };
}
// Anything that isn't a plain string is a structured-injection attempt
// (arrays and objects can survive into places a string wouldn't).
if (typeof raw !== 'string') return { ok: false, reason: 'email_invalid' };
const email = raw.trim();
if (email === '') {
return required ? { ok: false, reason: 'email_required' } : { ok: true, email: null };
}
// Length caps first — bounds every check that follows.
if (email.length > 254) return { ok: false, reason: 'email_too_long' };
// Control characters, including the newlines used for header injection.
if (CONTROL_CHARS_RX.test(email)) return { ok: false, reason: 'email_invalid' };
const at = email.indexOf('@');
if (at < 1 || at !== email.lastIndexOf('@')) return { ok: false, reason: 'email_invalid' };
const local = email.slice(0, at);
const domain = email.slice(at + 1);
if (local.length > 64 || domain.length > 253) return { ok: false, reason: 'email_too_long' };
if (email.includes('..')) return { ok: false, reason: 'email_invalid' };
if (!EMAIL_RX.test(email)) return { ok: false, reason: 'email_invalid' };
return { ok: true, email: email.toLowerCase() };
}
/**
* Run every protection layer for a public form POST.
* Returns { ok: true, ip } or { ok: false, status, error, reason }.
@@ -215,6 +275,8 @@ module.exports = {
turnstileEnabled,
issueFormToken,
guardSubmission,
validateEmail,
messageFor,
publicConfig,
clientIp,
};