← 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →