1 Commits

Author SHA1 Message Date
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
4 changed files with 73 additions and 5 deletions

25
app.js
View File

@@ -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;

View File

@@ -469,7 +469,7 @@
<div class="calc-grid">
<div class="calc-field">
<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 class="calc-field">
<label for="calcPropertyType">Property type</label>

View File

@@ -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,

View File

@@ -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.' });