Harden admin login: RBAC-safe controls, 2FA, bot-scoring, rate limits (#9)
Adds a layered set of protections around the admin login and the app edge.
Trust proxy (server/src/utils/trustProxy.js)
- Configurable via TRUST_PROXY; pin to the newt agent ("ptero") LAN IP so
X-Forwarded-For is trusted ONLY from that peer. A blanket "true" is
rejected (coerced to 1) to prevent XFF spoofing that would dodge every
IP-based control. DEBUG_TRUST_PROXY logs peer/XFF/req.ip to re-verify the
proxy IP without a redeploy. Documents the Omada static-reservation
assumption.
Login throttling (server/src/middleware/loginProtection.js, rateLimit.js)
- express-slow-down progressive delay + the existing hard rate cap + a
separate per-IP exponential backoff that persists across the rate window.
All failures return one generic message (no user/pass disclosure).
Honeypot (login form + auth.controller)
- Hidden, plausibly-named field ("company"); a filled value fails
generically and is scored as an unambiguous bot.
Optional per-user TOTP 2FA (speakeasy/qrcode)
- totp_secret/totp_enabled columns (+ idempotent migration). Self-service
Account page: enroll via QR, confirm a code to enable, code-gated disable.
- Login is two-step for enrolled users: after the password, a short-lived
signed challenge (stage:'totp', not a session) is required before the
real session is issued.
Bot / scanner scoring + IP ban (server/src/middleware/botScore.js)
- Weighted CMS-scanner paths (this app uses none). Junk paths 404 FIRST,
unconditionally — independent of score/ban state, so a scanner rotating
through fresh Cloudflare IPs gets no free pass. /wp-admin/install.php is
the top-weighted near-1-hit ban (worst offender in prod logs). Per-IP
score with quiet-period decay temp-bans an IP from ALL routes once past a
(deliberately low) threshold, to protect /admin from credential stuffing.
Failed logins and honeypot hits feed the same score.
- Periodic sweep evicts stale, unbanned, quiet entries so the in-memory
store can't grow unbounded; the interval is unref'd and cleared on
graceful shutdown.
Tests: node --test suite (40) covering trust-proxy parsing + live req.ip
(incl. pinned-IP), rate limiter + exponential backoff, honeypot rejection,
TOTP verify (enabled/disabled) + challenge-isn't-a-session, bot-score
threshold/decay/ban + junk-404-independence + install.php + store sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
228
server/src/middleware/botScore.js
Normal file
228
server/src/middleware/botScore.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// ── 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,
|
||||
}
|
||||
94
server/src/middleware/loginProtection.js
Normal file
94
server/src/middleware/loginProtection.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// ── Login brute-force protection ───────────────────────────────────────────
|
||||
//
|
||||
// Three independent layers guard the login endpoint:
|
||||
//
|
||||
// 1. slowDown — express-slow-down adds an increasing delay to each request
|
||||
// once a few have been made in the window (before the hard cap
|
||||
// bites), so a scripted burst is throttled but a human typing a
|
||||
// wrong password twice barely notices.
|
||||
// 2. loginLimiter (in rateLimit.js) — a hard 10-per-15-min cap per IP.
|
||||
// 3. backoffGuard — a per-IP exponential backoff tracked in a SEPARATE store
|
||||
// from the rate limiter, keyed on *failed* attempts. Because it
|
||||
// is separate it persists across the rate limiter's window
|
||||
// reset: an IP that keeps failing keeps getting locked out for
|
||||
// longer, independent of the sliding 15-min window.
|
||||
//
|
||||
// The backoff store is in-memory (single instance). Times take an optional `now`
|
||||
// so the escalation/decay is deterministic to test.
|
||||
|
||||
const slowDown = require('express-slow-down')
|
||||
|
||||
const log = require('../utils/logger')('loginprotect')
|
||||
|
||||
// Progressive delay: no delay for the first few attempts, then +0.5s each,
|
||||
// capped so a request never hangs too long.
|
||||
const slowLogin = slowDown({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
delayAfter: 3,
|
||||
delayMs: (used) => (used - 3) * 500,
|
||||
maxDelayMs: 20 * 1000,
|
||||
})
|
||||
|
||||
// Exponential backoff on consecutive failures.
|
||||
const BASE_MS = 1000 // first failure locks ~1s
|
||||
const MAX_MS = 15 * 60 * 1000 // cap a single lock at 15 min
|
||||
const RESET_MS = 30 * 60 * 1000 // forget the streak after this much quiet
|
||||
|
||||
// ip -> { count, blockedUntil, lastFailure }
|
||||
const store = new Map()
|
||||
|
||||
// Record a failed login and lengthen this IP's lockout. Returns ms locked.
|
||||
function recordFailure(ip, now = Date.now()) {
|
||||
let e = store.get(ip)
|
||||
if (!e || now - e.lastFailure > RESET_MS) {
|
||||
e = { count: 0, blockedUntil: 0, lastFailure: 0 }
|
||||
store.set(ip, e)
|
||||
}
|
||||
e.count += 1
|
||||
e.lastFailure = now
|
||||
const delay = Math.min(BASE_MS * 2 ** (e.count - 1), MAX_MS)
|
||||
e.blockedUntil = now + delay
|
||||
log.warn('login failure recorded', { ip, count: e.count, lockMs: delay })
|
||||
return delay
|
||||
}
|
||||
|
||||
// Clear an IP's failure streak after a successful login.
|
||||
function recordSuccess(ip) {
|
||||
store.delete(ip)
|
||||
}
|
||||
|
||||
// How long (ms) this IP is still locked out for, 0 if not locked.
|
||||
function retryAfterMs(ip, now = Date.now()) {
|
||||
const e = store.get(ip)
|
||||
if (!e || e.blockedUntil <= now) return 0
|
||||
return e.blockedUntil - now
|
||||
}
|
||||
|
||||
// Middleware: reject while the IP is in its backoff window. Generic message —
|
||||
// never reveals whether the username or the password was the problem.
|
||||
function backoffGuard(req, res, next) {
|
||||
const wait = retryAfterMs(req.ip)
|
||||
if (wait > 0) {
|
||||
res.set('Retry-After', String(Math.ceil(wait / 1000)))
|
||||
log.warn('login blocked by backoff', { ip: req.ip, retryAfterMs: wait })
|
||||
return res.status(429).json({ message: 'Too many login attempts. Please try again later.' })
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// Test helpers.
|
||||
function _reset() {
|
||||
store.clear()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
slowLogin,
|
||||
backoffGuard,
|
||||
recordFailure,
|
||||
recordSuccess,
|
||||
retryAfterMs,
|
||||
_reset,
|
||||
BASE_MS,
|
||||
MAX_MS,
|
||||
RESET_MS,
|
||||
}
|
||||
Reference in New Issue
Block a user