3 Commits

Author SHA1 Message Date
614a0edfa1 Merge pull request 'Block example.com emails and homesites under 10' (#26) from feature/block-example-domain-and-min-homesites into main
All checks were successful
Deploy to Production / deploy (push) Successful in 3s
2026-07-24 09:30:44 -04:00
1219117adf 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 <noreply@anthropic.com>
2026-07-24 09:30:15 -04:00
5f4af3886c Merge pull request 'Validate email addresses on both public form endpoints' (#25) from feature/strict-email-validation into main
All checks were successful
Deploy to Production / deploy (push) Successful in 6s
2026-07-23 08:25:55 -04:00
4 changed files with 73 additions and 5 deletions

23
app.js
View File

@@ -125,12 +125,24 @@
// gives immediate feedback instead of a round-trip. // 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}$/; 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) { function isValidEmail(v) {
if (v.length > 254 || v.indexOf('@') > 64) return false; if (v.length > 254 || v.indexOf('@') > 64) return false;
if (v.includes('..')) 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) { function showCalcError(msg) {
if (!calcErr) return; if (!calcErr) return;
calcErr.textContent = msg; calcErr.textContent = msg;
@@ -159,6 +171,12 @@
return; 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. // Email is optional, but anything entered must be a real address.
if (calcEmail && !isValidEmail(calcEmail)) { if (calcEmail && !isValidEmail(calcEmail)) {
showCalcError('Please enter a valid email address.'); showCalcError('Please enter a valid email address.');
@@ -243,10 +261,11 @@
// is stored. AI/service errors (502/503) fall through to the local result. // is stored. AI/service errors (502/503) fall through to the local result.
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (data.blocked || data.field === 'email') { if (data.blocked || data.field) {
setCalcLoading(false); setCalcLoading(false);
showCalcError(data.error || 'We could not verify this submission. Please try again.'); 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(); resetCaptcha();
await fetchFormToken(); // tokens are single-use; issue a fresh one await fetchFormToken(); // tokens are single-use; issue a fresh one
return; return;

View File

@@ -469,7 +469,7 @@
<div class="calc-grid"> <div class="calc-grid">
<div class="calc-field"> <div class="calc-field">
<label for="calcHomesites">Number of homesites</label> <label for="calcHomesites">Number of homesites</label>
<input type="number" id="calcHomesites" placeholder="e.g. 150" min="1" /> <input type="number" id="calcHomesites" placeholder="e.g. 150" min="10" />
</div> </div>
<div class="calc-field"> <div class="calc-field">
<label for="calcPropertyType">Property type</label> <label for="calcPropertyType">Property type</label>

View File

@@ -147,6 +147,8 @@ const MESSAGES = {
email_required: 'Please enter your email address.', email_required: 'Please enter your email address.',
email_invalid: 'Please enter a valid email address.', email_invalid: 'Please enter a valid email address.',
email_too_long: 'That email address is too long.', 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 }; 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. // C0/C1 control characters and DEL — includes the CR/LF used for header injection.
const CONTROL_CHARS_RX = /[\x00-\x1F\x7F-\x9F]/; 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. * Validate an email address.
* Returns { ok: true, email } with the normalised (trimmed, lower-cased) value, * 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 (local.length > 64 || domain.length > 253) return { ok: false, reason: 'email_too_long' };
if (email.includes('..')) return { ok: false, reason: 'email_invalid' }; if (email.includes('..')) return { ok: false, reason: 'email_invalid' };
if (!EMAIL_RX.test(email)) 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() }; 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. * Run every protection layer for a public form POST.
* Returns { ok: true, ip } or { ok: false, status, error, reason }. * Returns { ok: true, ip } or { ok: false, status, error, reason }.
@@ -276,6 +316,8 @@ module.exports = {
issueFormToken, issueFormToken,
guardSubmission, guardSubmission,
validateEmail, validateEmail,
validateHomesites,
MIN_HOMESITES,
messageFor, messageFor,
publicConfig, publicConfig,
clientIp, clientIp,

View File

@@ -234,10 +234,17 @@ app.post('/api/calculate', async (req, res) => {
} }
const cleanEmail = emailCheck.email; const cleanEmail = emailCheck.email;
if (!homesites || !annualIncome) { if (!annualIncome) {
return res.status(400).json({ error: 'homesites and annualIncome are required.' }); 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) { if (!aiClient) {
saveCalcSubmission(null); saveCalcSubmission(null);
return res.status(503).json({ error: 'AI service not configured.' }); return res.status(503).json({ error: 'AI service not configured.' });