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