From 1219117adfad574396d2fbd560b7cfd2cd656567 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Fri, 24 Jul 2026 09:30:15 -0400 Subject: [PATCH] Block example.com emails and homesites under 10 Spam submissions were still getting through with placeholder data. Two more content filters on the public form endpoints: - Reject emails from reserved documentation domains (example.com/.org/ /.net/.edu and subdomains) and reserved TLDs (.test/.example/.invalid/ localhost). testing@example.com and friends are never real leads. - Reject a homesites count below 10. Real associations are larger; the junk uses 0/1/2. Both are validated server-side in security.js (validateEmail gains a domain blocklist, new validateHomesites) and mirrored client-side in app.js for immediate feedback. The homesites input min attribute goes from 1 to 10. Blocked submissions return 400 with a `field` hint and store nothing. Co-Authored-By: Claude Opus 4.8 --- app.js | 25 ++++++++++++++++++++++--- index.html | 2 +- security.js | 42 ++++++++++++++++++++++++++++++++++++++++++ server.js | 9 ++++++++- 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/app.js b/app.js index 63efea9..21b4ff6 100644 --- a/app.js +++ b/app.js @@ -125,12 +125,24 @@ // 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}$/; + // Reserved documentation domains / TLDs — mirrors security.js. Real leads + // never come from these; spam bots use them. + const BLOCKED_EMAIL_DOMAINS = ['example.com', 'example.org', 'example.net', 'example.edu']; + const BLOCKED_EMAIL_TLDS = ['test', 'example', 'invalid', 'localhost']; + function isValidEmail(v) { if (v.length > 254 || v.indexOf('@') > 64) return false; if (v.includes('..')) return false; - return EMAIL_RX.test(v); + if (!EMAIL_RX.test(v)) return false; + const domain = v.slice(v.indexOf('@') + 1).toLowerCase(); + const tld = domain.slice(domain.lastIndexOf('.') + 1); + if (BLOCKED_EMAIL_TLDS.includes(tld)) return false; + if (BLOCKED_EMAIL_DOMAINS.some(d => domain === d || domain.endsWith('.' + d))) return false; + return true; } + const MIN_HOMESITES = 10; + function showCalcError(msg) { if (!calcErr) return; calcErr.textContent = msg; @@ -159,6 +171,12 @@ return; } + if (homesites < MIN_HOMESITES) { + showCalcError(`Please enter the number of homesites in your community (minimum ${MIN_HOMESITES}).`); + document.getElementById('calcHomesites')?.focus(); + return; + } + // Email is optional, but anything entered must be a real address. if (calcEmail && !isValidEmail(calcEmail)) { showCalcError('Please enter a valid email address.'); @@ -243,10 +261,11 @@ // 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 || data.field === 'email') { + if (data.blocked || data.field) { setCalcLoading(false); showCalcError(data.error || 'We could not verify this submission. Please try again.'); - if (data.field === 'email') document.getElementById('calcEmail')?.focus(); + if (data.field === 'email') document.getElementById('calcEmail')?.focus(); + if (data.field === 'homesites') document.getElementById('calcHomesites')?.focus(); resetCaptcha(); await fetchFormToken(); // tokens are single-use; issue a fresh one return; diff --git a/index.html b/index.html index 430aa9a..720b1f8 100644 --- a/index.html +++ b/index.html @@ -469,7 +469,7 @@
- +
diff --git a/security.js b/security.js index 298c04f..96e1025 100644 --- a/security.js +++ b/security.js @@ -147,6 +147,8 @@ const MESSAGES = { email_required: 'Please enter your email address.', email_invalid: 'Please enter a valid email address.', email_too_long: 'That email address is too long.', + email_blocked: 'Please use a valid work or personal email address.', + homesites_too_low: 'Please enter the number of homesites in your community (minimum 10).', }; const STATUS = { rate_limited: 429, honeypot: 400 }; @@ -167,6 +169,26 @@ const EMAIL_RX = /^[A-Za-z0-9](?:[A-Za-z0-9._%+-]{0,62}[A-Za-z0-9])?@[A-Za-z0-9] // C0/C1 control characters and DEL — includes the CR/LF used for header injection. const CONTROL_CHARS_RX = /[\x00-\x1F\x7F-\x9F]/; +// Domains that are never a real lead. These are the IANA reserved documentation +// domains (RFC 2606) plus reserved TLDs, which is what spam bots reach for. +// A submitted domain is blocked when it equals one of these or is a subdomain +// of one (e.g. `mail.example.com`). +const BLOCKED_EMAIL_DOMAINS = new Set([ + 'example.com', 'example.org', 'example.net', 'example.edu', +]); +const BLOCKED_EMAIL_TLDS = new Set(['test', 'example', 'invalid', 'localhost']); + +function isBlockedDomain(domain) { + const d = domain.toLowerCase(); + const tld = d.slice(d.lastIndexOf('.') + 1); + if (BLOCKED_EMAIL_TLDS.has(tld)) return true; + + for (const blocked of BLOCKED_EMAIL_DOMAINS) { + if (d === blocked || d.endsWith('.' + blocked)) return true; + } + return false; +} + /** * Validate an email address. * Returns { ok: true, email } with the normalised (trimmed, lower-cased) value, @@ -204,10 +226,28 @@ function validateEmail(raw, { required = true } = {}) { 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' }; + if (isBlockedDomain(domain)) return { ok: false, reason: 'email_blocked' }; return { ok: true, email: email.toLowerCase() }; } +// ── Homesites validation ───────────────────────────────── +// Real associations have at least this many units; smaller values are the +// placeholder junk (0, 1, 2…) the spam submissions use. +const MIN_HOMESITES = 10; + +/** + * Validate a homesites count. + * Returns { ok: true, homesites } (a finite number) or { ok: false, reason }. + */ +function validateHomesites(raw) { + const n = typeof raw === 'number' ? raw : parseFloat(raw); + if (!Number.isFinite(n) || n < MIN_HOMESITES) { + return { ok: false, reason: 'homesites_too_low' }; + } + return { ok: true, homesites: n }; +} + /** * Run every protection layer for a public form POST. * Returns { ok: true, ip } or { ok: false, status, error, reason }. @@ -276,6 +316,8 @@ module.exports = { issueFormToken, guardSubmission, validateEmail, + validateHomesites, + MIN_HOMESITES, messageFor, publicConfig, clientIp, diff --git a/server.js b/server.js index 65e53aa..50e8734 100644 --- a/server.js +++ b/server.js @@ -234,10 +234,17 @@ app.post('/api/calculate', async (req, res) => { } const cleanEmail = emailCheck.email; - if (!homesites || !annualIncome) { + if (!annualIncome) { return res.status(400).json({ error: 'homesites and annualIncome are required.' }); } + // Homesites must be a real community size; tiny/placeholder values are spam. + const homesitesCheck = security.validateHomesites(homesites); + if (!homesitesCheck.ok) { + console.warn(`[abuse] rejected homesites from ${guard.ip}: ${JSON.stringify(homesites)}`); + return res.status(400).json({ error: security.messageFor(homesitesCheck.reason), field: 'homesites' }); + } + if (!aiClient) { saveCalcSubmission(null); return res.status(503).json({ error: 'AI service not configured.' });