← back to Robet Site
overnight loop item (e): contact-form hardening — honeypot + email validation + length caps + per-IP rate limit
06d8277d5ec9947bb5e4d8237bf3a843f66def6d · 2026-06-29 18:44:24 -0700 · Steve
Files touched
M public/app.jsM public/index.htmlM public/styles.cssM server.js
Diff
commit 06d8277d5ec9947bb5e4d8237bf3a843f66def6d
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jun 29 18:44:24 2026 -0700
overnight loop item (e): contact-form hardening — honeypot + email validation + length caps + per-IP rate limit
---
public/app.js | 2 +-
public/index.html | 1 +
public/styles.css | 1 +
server.js | 27 +++++++++++++++++++++++----
4 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/public/app.js b/public/app.js
index fb8f942..05855b4 100644
--- a/public/app.js
+++ b/public/app.js
@@ -72,7 +72,7 @@
$('#contactForm').addEventListener('submit', async (e) => {
e.preventDefault();
const msg = $('#formMsg');
- const body = { name: $('#cName').value, email: $('#cEmail').value, message: $('#cMsg').value };
+ const body = { name: $('#cName').value, email: $('#cEmail').value, message: $('#cMsg').value, website: $('#hp_website').value };
msg.textContent = 'Sending…';
try {
const r = await fetch('/api/contact', {
diff --git a/public/index.html b/public/index.html
index 5bf707f..3a8b1e9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -70,6 +70,7 @@ MEETING.</h1>
<div class="contact-wrap">
<dl class="contact-info" id="contactInfo"></dl>
<form class="contact-form" id="contactForm">
+ <input type="text" name="website" id="hp_website" class="hp" tabindex="-1" autocomplete="off" aria-hidden="true" />
<label for="cName">Name</label>
<input id="cName" name="name" autocomplete="name" />
<label for="cEmail">Email *</label>
diff --git a/public/styles.css b/public/styles.css
index 54d2119..bb224cd 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -116,6 +116,7 @@ section { padding: 70px 24px; border-bottom: var(--bord); }
text-transform: uppercase; font-size: 18px; padding: 14px 26px; cursor: pointer; }
.contact-form button:hover { background: var(--accent2); }
.formmsg { margin-top: 12px; font-family: "SF Mono", monospace; }
+.hp { position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
/* footer */
footer { padding: 40px 24px; background: var(--ink); color: var(--paper); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
diff --git a/server.js b/server.js
index 343d421..c069890 100644
--- a/server.js
+++ b/server.js
@@ -56,12 +56,31 @@ function adminAuth(req, res, next) {
// ── public config (secrets stripped) ──
app.get('/api/config', (_req, res) => res.json(readJson(CONFIG_FILE, {})));
-// ── contact form ──
+// ── contact form (hardened: honeypot + validation + per-IP rate limit) ──
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const rateHits = new Map(); // ip -> [timestamps]
+function rateLimited(ip, max = 5, windowMs = 10 * 60 * 1000) {
+ const now = Date.now();
+ const arr = (rateHits.get(ip) || []).filter((t) => now - t < windowMs);
+ arr.push(now);
+ rateHits.set(ip, arr);
+ if (rateHits.size > 5000) rateHits.delete(rateHits.keys().next().value); // bound memory
+ return arr.length > max;
+}
+const clientIp = (req) =>
+ String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
+
app.post('/api/contact', (req, res) => {
- const { name = '', email = '', message = '' } = req.body || {};
- if (!email || !message) return res.status(400).json({ ok: false, error: 'email and message required' });
+ const { name = '', email = '', message = '', website = '' } = req.body || {};
+ if (website) return res.json({ ok: true }); // honeypot → silently drop bot
+ if (rateLimited(clientIp(req))) return res.status(429).json({ ok: false, error: 'Too many submissions — try again later.' });
+ const n = String(name).slice(0, 120).trim();
+ const e = String(email).slice(0, 200).trim();
+ const m = String(message).slice(0, 5000).trim();
+ if (!EMAIL_RE.test(e)) return res.status(400).json({ ok: false, error: 'A valid email is required.' });
+ if (!m) return res.status(400).json({ ok: false, error: 'A message is required.' });
fs.mkdirSync(path.dirname(CONTACTS_FILE), { recursive: true });
- fs.appendFileSync(CONTACTS_FILE, JSON.stringify({ name, email, message, at: new Date().toISOString() }) + '\n');
+ fs.appendFileSync(CONTACTS_FILE, JSON.stringify({ name: n, email: e, message: m, ip: clientIp(req), at: new Date().toISOString() }) + '\n');
res.json({ ok: true });
});
← 5d4d51b loop ledger: item (c) done + deployed
·
back to Robet Site
·
loop ledger: item (e) done + deployed f55316d →