[object Object]

← back to Ticket System

Add brute-force lockout to public ticket board (interim hardening pre-CF-Access): 10 fails/15min → 429, loopback-exempt

64775fd1aba60f4874149612d319f73a4f2ae9d5 · 2026-08-25 12:12:26 -0700 · Steve

Files touched

Diff

commit 64775fd1aba60f4874149612d319f73a4f2ae9d5
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 12:12:26 2026 -0700

    Add brute-force lockout to public ticket board (interim hardening pre-CF-Access): 10 fails/15min → 429, loopback-exempt
---
 server.js | 46 +++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 45 insertions(+), 1 deletion(-)

diff --git a/server.js b/server.js
index ff076e6b..1ccd053b 100644
--- a/server.js
+++ b/server.js
@@ -53,6 +53,47 @@ const BOARD_HTML = path.join(__dirname, 'board.html');
 const PORT = process.env.PORT || 9794;
 const AUTH = 'Basic ' + Buffer.from(process.env.TK_AUTH || 'admin:DW2024!').toString('base64');
 
+// ── brute-force lockout (interim hardening while CF Zero Trust Access is pending) ──
+// The board is now PUBLICLY exposed via the dedicated `tickets` tunnel and its
+// authenticated endpoints spawn Claude sessions (/api/run) — a lockout-less shared
+// Basic cred on the open internet is dictionary-attackable at line speed. Track failed
+// auths per client IP; after FAIL_MAX inside FAIL_WINDOW_MS, that IP is 429'd for LOCK_MS.
+// A correct auth clears the record. In-memory only, pruned so the map can't grow.
+// Loopback (direct 127.0.0.1, not tunnel-proxied) is exempt so local use never locks.
+// Ported from ~/Projects/dw-pitch-followup/server.js. Reversible: delete this block +
+// restore the plain auth check below.
+const authFails = new Map(); // ip -> { count, first, until }
+const FAIL_MAX = 10, FAIL_WINDOW_MS = 15 * 60 * 1000, LOCK_MS = 15 * 60 * 1000;
+const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
+function clientIp(req) {
+  return req.headers['cf-connecting-ip']
+    || (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
+    || (req.socket && req.socket.remoteAddress) || 'unknown';
+}
+// Returns null if the request may proceed to the auth check, or a {code,msg} to reject.
+function lockoutGate(req) {
+  const remote = req.socket && req.socket.remoteAddress;
+  const proxied = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'];
+  if (LOOPBACK.has(remote) && !proxied) return null;      // direct local access — never locked
+  const ip = clientIp(req), now = Date.now();
+  const rec = authFails.get(ip);
+  if (rec && rec.until && now < rec.until) return { code: 429, msg: 'too many failed attempts — try again later', retry: Math.ceil((rec.until - now) / 1000) };
+  return null;
+}
+function noteAuth(req, ok) {
+  const remote = req.socket && req.socket.remoteAddress;
+  const proxied = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'];
+  if (LOOPBACK.has(remote) && !proxied) return;
+  const ip = clientIp(req), now = Date.now();
+  if (ok) { authFails.delete(ip); return; }
+  let rec = authFails.get(ip);
+  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 > 5000) for (const [k, v] of authFails) if (!v.until || now > v.until) authFails.delete(k); // prune
+}
+
 const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
 
 // ── priority ranking + per-ticket ratings (mirrors the :9801 approvals viewer scoreGate, TK-10695) ──
@@ -223,7 +264,10 @@ ${dmPanel}
 
 http.createServer((req, res) => {
   if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }
-  if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="tickets"' }); return res.end('auth'); }
+  const locked = lockoutGate(req);
+  if (locked) { res.writeHead(locked.code, { 'Retry-After': String(locked.retry) }); return res.end(locked.msg); }
+  if (req.headers.authorization !== AUTH) { noteAuth(req, false); res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="tickets"' }); return res.end('auth'); }
+  noteAuth(req, true);
 
   // ── writes / actions (all auth-gated; ids resolved server-side from the real store) ──
 

← a9217088 ticket-board: send Cache-Control: no-store on board + API re  ·  back to Ticket System  ·  Loopback-gate /api/run + /api/dtd: block Claude-spawning end 4f405809 →