Compare commits
5 Commits
feature/ro
...
614a0edfa1
| Author | SHA1 | Date | |
|---|---|---|---|
| 614a0edfa1 | |||
| 1219117adf | |||
| 5f4af3886c | |||
| 156adeab59 | |||
| c6425887f1 |
40
app.js
40
app.js
@@ -121,6 +121,28 @@
|
|||||||
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
|
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
|
||||||
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
|
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}$/;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
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;
|
||||||
@@ -148,6 +170,20 @@
|
|||||||
showCalcError('Please fill in homesites and annual dues income to continue.');
|
showCalcError('Please fill in homesites and annual dues income to continue.');
|
||||||
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.
|
||||||
|
if (calcEmail && !isValidEmail(calcEmail)) {
|
||||||
|
showCalcError('Please enter a valid email address.');
|
||||||
|
document.getElementById('calcEmail')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
calcErr.classList.add('hidden');
|
calcErr.classList.add('hidden');
|
||||||
setCalcLoading(true);
|
setCalcLoading(true);
|
||||||
|
|
||||||
@@ -225,9 +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) {
|
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 === '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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
104
security.js
104
security.js
@@ -144,10 +144,110 @@ const MESSAGES = {
|
|||||||
captcha_missing: 'Please complete the verification challenge.',
|
captcha_missing: 'Please complete the verification challenge.',
|
||||||
captcha_failed: 'Verification failed. Please try the challenge again.',
|
captcha_failed: 'Verification failed. Please try the challenge again.',
|
||||||
captcha_unavailable: 'Verification is temporarily unavailable. Please try again in a moment.',
|
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.',
|
||||||
|
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 };
|
||||||
|
|
||||||
|
/** 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]/;
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
* 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' };
|
||||||
|
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.
|
* 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 }.
|
||||||
@@ -215,6 +315,10 @@ module.exports = {
|
|||||||
turnstileEnabled,
|
turnstileEnabled,
|
||||||
issueFormToken,
|
issueFormToken,
|
||||||
guardSubmission,
|
guardSubmission,
|
||||||
|
validateEmail,
|
||||||
|
validateHomesites,
|
||||||
|
MIN_HOMESITES,
|
||||||
|
messageFor,
|
||||||
publicConfig,
|
publicConfig,
|
||||||
clientIp,
|
clientIp,
|
||||||
};
|
};
|
||||||
|
|||||||
51
server.js
51
server.js
@@ -129,36 +129,41 @@ app.get('/api/form-token', (_req, res) => {
|
|||||||
app.post('/api/leads', (req, res) => {
|
app.post('/api/leads', (req, res) => {
|
||||||
const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {};
|
const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {};
|
||||||
|
|
||||||
|
// Coerce defensively: a non-string (array, object, number) would otherwise
|
||||||
|
// blow up on .trim() and surface as a 500 instead of a 400.
|
||||||
|
const str = v => (typeof v === 'string' ? v.trim() : '');
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!firstName?.trim() || !lastName?.trim() || !email?.trim()) {
|
if (!str(firstName) || !str(lastName) || !str(email)) {
|
||||||
return res.status(400).json({ error: 'firstName, lastName, and email are required.' });
|
return res.status(400).json({ error: 'firstName, lastName, and email are required.' });
|
||||||
}
|
}
|
||||||
if (!orgName?.trim()) {
|
if (!str(orgName)) {
|
||||||
return res.status(400).json({ error: 'Organization name is required.' });
|
return res.status(400).json({ error: 'Organization name is required.' });
|
||||||
}
|
}
|
||||||
if (!state?.trim()) {
|
if (!str(state)) {
|
||||||
return res.status(400).json({ error: 'State is required.' });
|
return res.status(400).json({ error: 'State is required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple email format check
|
// Strict email format check — see security.validateEmail
|
||||||
const emailRx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailCheck = security.validateEmail(email);
|
||||||
if (!emailRx.test(email.trim())) {
|
if (!emailCheck.ok) {
|
||||||
return res.status(400).json({ error: 'Invalid email address.' });
|
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
|
||||||
}
|
}
|
||||||
|
const cleanEmail = emailCheck.email;
|
||||||
|
|
||||||
// Check for duplicate
|
// Check for duplicate
|
||||||
const existing = findByEmail.get(email.trim().toLowerCase());
|
const existing = findByEmail.get(cleanEmail);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return res.status(409).json({ error: 'This email is already on the list.', id: existing.id });
|
return res.status(409).json({ error: 'This email is already on the list.', id: existing.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = insertLead.run({
|
const info = insertLead.run({
|
||||||
firstName: firstName.trim(),
|
firstName: str(firstName),
|
||||||
lastName: lastName.trim(),
|
lastName: str(lastName),
|
||||||
email: email.trim().toLowerCase(),
|
email: cleanEmail,
|
||||||
orgName: orgName?.trim() ?? null,
|
orgName: str(orgName) || null,
|
||||||
state: state?.trim() ?? null,
|
state: str(state) || null,
|
||||||
role: role ?? null,
|
role: role ?? null,
|
||||||
unitCount: unitCount ?? null,
|
unitCount: unitCount ?? null,
|
||||||
betaInterest: betaInterest ? 1 : 0,
|
betaInterest: betaInterest ? 1 : 0,
|
||||||
@@ -190,7 +195,7 @@ app.post('/api/calculate', async (req, res) => {
|
|||||||
function saveCalcSubmission(aiRecommendation) {
|
function saveCalcSubmission(aiRecommendation) {
|
||||||
try {
|
try {
|
||||||
insertCalcSubmission.run({
|
insertCalcSubmission.run({
|
||||||
email: email?.trim() || null,
|
email: cleanEmail,
|
||||||
optIn: optIn ? 1 : 0,
|
optIn: optIn ? 1 : 0,
|
||||||
homesites: homesites || null,
|
homesites: homesites || null,
|
||||||
propertyType: propertyType || null,
|
propertyType: propertyType || null,
|
||||||
@@ -220,10 +225,26 @@ app.post('/api/calculate', async (req, res) => {
|
|||||||
email, optIn, totalPotential, opInterest, resInterest,
|
email, optIn, totalPotential, opInterest, resInterest,
|
||||||
} = req.body ?? {};
|
} = req.body ?? {};
|
||||||
|
|
||||||
if (!homesites || !annualIncome) {
|
// Email is optional here, but if one is supplied it must be a real address.
|
||||||
|
// Nothing is stored or sent onward until it passes.
|
||||||
|
const emailCheck = security.validateEmail(email, { required: false });
|
||||||
|
if (!emailCheck.ok) {
|
||||||
|
console.warn(`[abuse] rejected email from ${guard.ip}: ${JSON.stringify(String(email).slice(0, 120))}`);
|
||||||
|
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
|
||||||
|
}
|
||||||
|
const cleanEmail = emailCheck.email;
|
||||||
|
|
||||||
|
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.' });
|
||||||
|
|||||||
Reference in New Issue
Block a user