// ── Bot / scanner scoring and IP banning ────────────────────────────────── // // This app has no WordPress, Drupal, phpMyAdmin, .env exposure, etc. Any hit on // those well-known scanner targets is therefore pure bot signal. Two separate // jobs happen here, and it matters that they stay separate: // // 1. Junk-path 404: every hit to a known scanner path is 404'd immediately and // UNCONDITIONALLY — independent of any IP score or ban state. Much of the // scanning traffic here comes through Cloudflare edge ranges (104.23.x, // 162.158.x, 172.68-71.x), i.e. a large rotating pool of source IPs, so we // must never give a fresh IP a "free pass" on a junk path while its score // warms up. The 404 is the primary, always-on defense. // // 2. Per-IP temp-ban: scoring accumulates per IP and, past a threshold, bans // that IP from ALL routes for a while. This exists mainly to protect the // real /admin login from credential stuffing once a scanner pivots from // probing junk to attacking login — NOT to stop the scanning itself (fresh // IPs are cheap for this actor, so an IP ban can't win that race). Because // of that we bias toward a slightly LOWER threshold rather than a high one // tuned to avoid false positives from a small/stable IP pool. // // State is a single-instance in-memory Map — fine for one Node process. Scores // decay after a quiet period so a transient burst does not ban an IP forever. // // All time-based logic takes an optional `now` argument (defaulting to // Date.now()) so the decay/ban windows are deterministic to test. const log = require('../utils/logger')('botscore') // Score at/above which an IP is banned from ALL routes. Deliberately on the low // side (see job #2 above): fresh IPs are cheap for this actor, so we'd rather // ban an attacking IP a little early than tune high to protect a stable pool. const BAN_THRESHOLD = 80 // How long a ban lasts. const BAN_MS = 60 * 60 * 1000 // 1 hour // Quiet period after which a non-banned IP's accumulated score resets to 0. const QUIET_MS = 30 * 60 * 1000 // 30 min // Points added for a failed /admin login (wired in from the auth controller). const LOGIN_FAIL_POINTS = 34 // Points for a tripped honeypot — an unambiguous bot, ban on sight. const HONEYPOT_POINTS = BAN_THRESHOLD // Weighted scanner paths, matched as a prefix against the lowercased request // path, FIRST match wins — so more specific paths must precede their prefixes // (e.g. /wp-admin/install.php before /wp-admin). Heavier weights = more damning. // // /wp-admin/install.php is by far the most-hit junk path in the real Pangolin // access logs (from many rotating IPs), so it carries the single highest weight: // a lone hit exceeds the ban threshold on its own — effectively a 1-hit ban — // and outweighs every other individual path. const PATH_WEIGHTS = [ ['/wp-admin/install.php', 200], // top offender in prod logs — near 1-hit ban ['/.env', 100], ['/.git', 100], ['/.aws', 100], ['/wp-login.php', 100], ['/xmlrpc.php', 100], ['/wp-admin', 50], ['/administrator', 50], ['/phpmyadmin', 50], ['/mysql', 50], ['/wp-content', 40], ['/wp-includes', 40], ['/wp-json', 40], ['/user/login', 40], // Drupal ['/console', 40], ['/actuator', 40], // Spring Boot ['/vendor/phpunit', 100], ['/cgi-bin', 40], ] // ip -> { score, lastSeen, bannedUntil } const store = new Map() // Return the scanner weight for a request path (0 if it is a legitimate path). function scoreForPath(pathname) { const p = String(pathname || '').toLowerCase() for (const [prefix, weight] of PATH_WEIGHTS) { if (p === prefix || p.startsWith(prefix)) return weight } return 0 } function getEntry(ip) { let e = store.get(ip) if (!e) { e = { score: 0, lastSeen: 0, bannedUntil: 0 } store.set(ip, e) } return e } function isBanned(ip, now = Date.now()) { const e = store.get(ip) return Boolean(e && e.bannedUntil > now) } // Add points to an IP's score. Applies quiet-period decay first, then bans the // IP if the new score crosses the threshold. Returns the updated entry. function addScore(ip, points, now = Date.now(), reason = 'scan') { const e = getEntry(ip) // Decay: if the IP has been quiet longer than QUIET_MS (and is not currently // banned), forget its accumulated score before adding the new hit. if (e.bannedUntil <= now && e.lastSeen && now - e.lastSeen > QUIET_MS) { e.score = 0 } e.score += points e.lastSeen = now if (e.score >= BAN_THRESHOLD && e.bannedUntil <= now) { e.bannedUntil = now + BAN_MS log.warn('IP banned', { ip, score: e.score, reason, banMs: BAN_MS }) } return e } // Points for a failed real login — called from the auth controller. function recordLoginFailure(ip, now = Date.now()) { return addScore(ip, LOGIN_FAIL_POINTS, now, 'login-fail') } // A tripped honeypot: instant ban-worthy score. function recordHoneypot(ip, now = Date.now()) { return addScore(ip, HONEYPOT_POINTS, now, 'honeypot') } // Early middleware: mounted before routing so banned IPs never reach a real // handler. Everything here 404s (never 403) so we never confirm a path or a ban. function guard(req, res, next) { const ip = req.ip const now = Date.now() // (1) Known junk/scanner path → 404 FIRST, unconditionally. This is evaluated // and returned before any ban check, so the 404 is fully independent of this // IP's score/ban state: a scanner cycling through fresh Cloudflare IPs gets no // free pass on a junk path. Scoring still runs (it accrues toward a /admin ban // if the IP is reused), but the 404 does not depend on it. const points = scoreForPath(req.path) if (points > 0) { const e = addScore(ip, points, now, 'scan') log.warn('scanner path hit', { ip, path: req.path, points, score: e.score }) return notFound(res) } // (2) Non-junk path → block only if this IP is already banned (the // credential-stuffing guard for the real /admin login), else let it through. if (isBanned(ip, now)) { log.debug('blocked banned IP', { ip, path: req.path }) return notFound(res) } return next() } // Uniform 404 — mirrors the SPA/API "Not found" shape without leaking anything. function notFound(res) { return res.status(404).json({ message: 'Not found' }) } // ── Cleanup sweep ────────────────────────────────────────────────────────── // Every unique IP that hits a scored path adds an entry and nothing else evicts // it, so the store would grow unbounded. Periodically drop entries that are no // longer meaningful: NOT banned and quiet longer than QUIET_MS (their score // would already reset to 0 on next touch anyway). Banned entries, and entries // still inside their quiet decay window, are left untouched. Returns the count // removed. The eviction age reuses QUIET_MS; SWEEP_INTERVAL_MS is only cadence. const SWEEP_INTERVAL_MS = 10 * 60 * 1000 // 10 min function sweep(now = Date.now()) { let removed = 0 for (const [ip, e] of store) { if (e.bannedUntil <= now && now - e.lastSeen > QUIET_MS) { store.delete(ip) removed++ } } if (removed > 0) log.debug('store sweep', { removed, remaining: store.size }) return removed } let sweepTimer = null function startSweeper() { if (sweepTimer) return sweepTimer sweepTimer = setInterval(() => sweep(), SWEEP_INTERVAL_MS) // Never let the sweep timer alone keep the event loop alive (tests, shutdown). if (sweepTimer.unref) sweepTimer.unref() return sweepTimer } function stopSweeper() { if (sweepTimer) { clearInterval(sweepTimer) sweepTimer = null } } // Start sweeping on load — this is a single long-lived process. startSweeper() // Test/ops helpers. function _reset() { store.clear() } function _snapshot(ip) { const e = store.get(ip) return e ? { ...e } : null } module.exports = { guard, scoreForPath, addScore, isBanned, recordLoginFailure, recordHoneypot, sweep, startSweeper, stopSweeper, _reset, _snapshot, // Exported for tests / tuning. BAN_THRESHOLD, BAN_MS, QUIET_MS, LOGIN_FAIL_POINTS, HONEYPOT_POINTS, SWEEP_INTERVAL_MS, }