[object Object]

← 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

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 →