[object Object]

← back to Dw Pitch Followup

contrarian FIX FIRST #2: per-IP brute-force lockout on public Basic Auth (10 fails/15m → 429)

577f14f50aefa02aa8cb08896ce4c810e4bee4c9 · 2026-07-12 11:30:02 -0700 · Steve Abrams

Files touched

Diff

commit 577f14f50aefa02aa8cb08896ce4c810e4bee4c9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 12 11:30:02 2026 -0700

    contrarian FIX FIRST #2: per-IP brute-force lockout on public Basic Auth (10 fails/15m → 429)
---
 server.js | 32 +++++++++++++++++++++++++++++++-
 1 file changed, 31 insertions(+), 1 deletion(-)

diff --git a/server.js b/server.js
index dfb5f65..3637bbc 100644
--- a/server.js
+++ b/server.js
@@ -62,12 +62,42 @@ app.use(express.json({ limit: '512kb' }));
 const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DW2024!';
 const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
 const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
+
+// Brute-force lockout (contrarian FIX FIRST #2): the public tunnel exposes Basic Auth,
+// which without a lockout is guessable at line speed. Track failed auths per client IP;
+// after FAIL_MAX failures inside FAIL_WINDOW_MS, that IP is 429'd for LOCK_MS. A correct
+// auth clears the record. In-memory only (tiny traffic); pruned so the map can't grow.
+const authFails = new Map(); // ip -> { count, first, until }
+const FAIL_MAX = 10, FAIL_WINDOW_MS = 15 * 60 * 1000, LOCK_MS = 15 * 60 * 1000;
+function clientIp(req) {
+  return req.get('cf-connecting-ip')
+    || (req.get('x-forwarded-for') || '').split(',')[0].trim()
+    || (req.socket && req.socket.remoteAddress) || 'unknown';
+}
 app.use((req, res, next) => {
   if (req.path === '/healthz' || req.path === '/favicon.ico') return next();
   const remote = req.socket && req.socket.remoteAddress;
   const proxied = req.get('cf-connecting-ip') || req.get('x-forwarded-for');
   if (LOOPBACK.has(remote) && !proxied) return next();   // direct local access
-  if (req.get('authorization') === AUTH_HEADER) return next();
+
+  const ip = clientIp(req), now = Date.now();
+  let rec = authFails.get(ip);
+  if (rec && rec.until && now < rec.until) {              // currently locked out
+    res.set('Retry-After', String(Math.ceil((rec.until - now) / 1000)));
+    return res.status(429).send('too many failed attempts — try again later');
+  }
+  if (req.get('authorization') === AUTH_HEADER) {
+    if (rec) authFails.delete(ip);                        // success clears the record
+    return next();
+  }
+  // failed auth → record + maybe lock
+  if (!rec || (now - rec.first) > FAIL_WINDOW_MS) rec = { count: 0, first: now, until: 0 };
+  rec.count++;
+  if (rec.count >= FAIL_MAX) rec.until = now + LOCK_MS;
+  authFails.set(ip, rec);
+  if (authFails.size > 2000) {                            // prune stale entries
+    for (const [k, v] of authFails) if ((!v.until || now >= v.until) && (now - v.first) > FAIL_WINDOW_MS) authFails.delete(k);
+  }
   res.set('WWW-Authenticate', 'Basic realm="dw-pitch-followup"');
   res.status(401).send('auth required');
 });

← cf2ad96 contrarian FIX FIRST #1: block /api/send for tunnel-origin r  ·  back to Dw Pitch Followup  ·  contrarian FIX FIRST #3: rotate public cred off shared DW202 09bfb88 →