[object Object]

← back to Consulting Intake

harden public /api/intake: per-IP + global rate limiting (no new deps)

cdc706727a44c174825bb836affdb6f0180cb26f · 2026-07-25 10:39:47 -0700 · Steve Abrams

Zero-dependency in-memory sliding-window limiter: 5 submissions/IP/10min plus a
500/day global backstop so distributed spam can't fill intakes.json. trust proxy
so it keys on the real client IP through nginx (X-Forwarded-For), not loopback.
Verified: 5x200 then 429 same-IP; distinct IPs get independent buckets.

Files touched

Diff

commit cdc706727a44c174825bb836affdb6f0180cb26f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jul 25 10:39:47 2026 -0700

    harden public /api/intake: per-IP + global rate limiting (no new deps)
    
    Zero-dependency in-memory sliding-window limiter: 5 submissions/IP/10min plus a
    500/day global backstop so distributed spam can't fill intakes.json. trust proxy
    so it keys on the real client IP through nginx (X-Forwarded-For), not loopback.
    Verified: 5x200 then 429 same-IP; distinct IPs get independent buckets.
---
 server.js | 32 +++++++++++++++++++++++++++++++-
 1 file changed, 31 insertions(+), 1 deletion(-)

diff --git a/server.js b/server.js
index d39194c..9a33d4e 100644
--- a/server.js
+++ b/server.js
@@ -19,8 +19,38 @@ const express = require('express');
 try { require('dotenv').config(); } catch { /* dotenv optional */ }
 
 const app = express();
+app.set('trust proxy', 1); // behind nginx on loopback — read the real client IP from X-Forwarded-For
 app.use(express.json({ limit: '1mb' }));
 const PORT = process.env.PORT || 9700;
+
+// ---- lightweight in-memory rate limiter (no dependency) --------------------
+// Sliding window keyed by IP (or a constant for a global cap). Public POST only.
+// State resets on restart — fine for spam mitigation. keyFn lets us run a
+// per-IP limiter and a global backstop from the same helper.
+function rateLimit({ windowMs, max, keyFn = (req) => req.ip || 'unknown' }) {
+  const hits = new Map(); // key -> [timestamps]
+  const sweep = setInterval(() => {
+    const cutoff = Date.now() - windowMs;
+    for (const [k, arr] of hits) {
+      const kept = arr.filter((t) => t > cutoff);
+      if (kept.length) hits.set(k, kept); else hits.delete(k);
+    }
+  }, windowMs);
+  sweep.unref();
+  return (req, res, next) => {
+    const now = Date.now(), cutoff = now - windowMs, k = keyFn(req);
+    const arr = (hits.get(k) || []).filter((t) => t > cutoff);
+    if (arr.length >= max) {
+      res.set('Retry-After', String(Math.ceil((arr[0] + windowMs - now) / 1000)));
+      return res.status(429).json({ ok: false, error: 'Too many submissions — please try again later.' });
+    }
+    arr.push(now); hits.set(k, arr); next();
+  };
+}
+// 5 submissions per IP per 10 min (a real client submits once), plus a global
+// 500/day backstop so distributed spam can't fill intakes.json / the disk.
+const intakeLimit = rateLimit({ windowMs: 10 * 60 * 1000, max: 5 });
+const intakeGlobalLimit = rateLimit({ windowMs: 24 * 60 * 60 * 1000, max: 500, keyFn: () => 'global' });
 const PUB = path.join(__dirname, 'public');
 const DATA = path.join(__dirname, 'data');
 
@@ -46,7 +76,7 @@ app.get('/', (_req, res) => res.sendFile(path.join(PUB, 'signin.html')));
 app.get('/intake', (_req, res) => res.sendFile(path.join(PUB, 'intake.html')));
 app.get('/api/health', (_req, res) => res.json({ ok: true, client: 'Consulting Intake', at: new Date().toISOString() }));
 app.get('/api/client', (_req, res) => res.json(readJSON('client.json', {}))); // public-safe summary for signin page
-app.post('/api/intake', (req, res) => {
+app.post('/api/intake', intakeGlobalLimit, intakeLimit, (req, res) => {
   const all = readJSON('intakes.json', []);
   all.push({ received_at: new Date().toISOString(), source: 'form', intake: req.body || {} });
   writeJSON('intakes.json', all);

← c3c30a5 intake sign-in: hero Ken-Burns pan-zoom + reduced-motion gua  ·  back to Consulting Intake  ·  chore: lint (intake write try/catch), v0.1.2 (session close) 6a3fbc6 →