← 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 →