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>
193 lines
7.5 KiB
JavaScript
193 lines
7.5 KiB
JavaScript
const { test, beforeEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const botScore = require('../src/middleware/botScore')
|
|
const { startApp } = require('./_helper')
|
|
|
|
beforeEach(() => botScore._reset())
|
|
|
|
test('scoreForPath: scanner paths score, legitimate app paths do not', () => {
|
|
assert.ok(botScore.scoreForPath('/wp-admin') > 0)
|
|
assert.ok(botScore.scoreForPath('/wp-login.php') > 0)
|
|
assert.ok(botScore.scoreForPath('/.env') > 0)
|
|
assert.ok(botScore.scoreForPath('/xmlrpc.php') > 0)
|
|
// The real admin path and API are NOT scanner signal.
|
|
assert.equal(botScore.scoreForPath('/admin'), 0)
|
|
assert.equal(botScore.scoreForPath('/admin/login'), 0)
|
|
assert.equal(botScore.scoreForPath('/api/v1/auth/login'), 0)
|
|
assert.equal(botScore.scoreForPath('/'), 0)
|
|
})
|
|
|
|
test('/wp-admin/install.php is the single highest-weighted path (near 1-hit ban)', () => {
|
|
const install = botScore.scoreForPath('/wp-admin/install.php')
|
|
// Higher than the generic /wp-admin prefix (more specific entry wins first)...
|
|
assert.ok(install > botScore.scoreForPath('/wp-admin'))
|
|
// ...and higher than every other scanner path.
|
|
for (const p of ['/.env', '/.git', '/wp-login.php', '/xmlrpc.php', '/phpmyadmin', '/wp-json']) {
|
|
assert.ok(install > botScore.scoreForPath(p), `install.php should outweigh ${p}`)
|
|
}
|
|
// A single hit alone meets/exceeds the ban threshold → effectively a 1-hit ban.
|
|
assert.ok(install >= botScore.BAN_THRESHOLD)
|
|
})
|
|
|
|
test('install.php 404s on a first-time-seen IP (before any ban would trigger)', async () => {
|
|
const freshIp = '203.0.113.80'
|
|
// Precondition: this IP has never been seen, so it is NOT banned yet.
|
|
assert.equal(botScore.isBanned(freshIp), false)
|
|
const app = await startApp((a) => {
|
|
a.set('trust proxy', 1)
|
|
a.use(botScore.guard)
|
|
a.get('/', (req, res) => res.json({ ok: true }))
|
|
})
|
|
try {
|
|
// The very first hit to the junk path must 404 — the 404 comes from the
|
|
// junk-path rule, independent of the (currently empty) ban state.
|
|
const res = await fetch(`${app.url}/wp-admin/install.php`, {
|
|
headers: { 'X-Forwarded-For': freshIp },
|
|
})
|
|
assert.equal(res.status, 404)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('a low-weight junk path 404s on first hit WITHOUT a ban (404 is ban-independent)', async () => {
|
|
const freshIp = '203.0.113.81'
|
|
const app = await startApp((a) => {
|
|
a.set('trust proxy', 1)
|
|
a.use(botScore.guard)
|
|
a.get('/', (req, res) => res.json({ ok: true }))
|
|
})
|
|
try {
|
|
// /wp-content scores 40 (< threshold 80): the hit 404s but does NOT ban,
|
|
// proving the immediate 404 does not depend on the IP having crossed the
|
|
// ban threshold.
|
|
const res = await fetch(`${app.url}/wp-content/uploads/x.php`, {
|
|
headers: { 'X-Forwarded-For': freshIp },
|
|
})
|
|
assert.equal(res.status, 404)
|
|
assert.equal(botScore.isBanned(freshIp), false)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('addScore bans once the threshold is crossed', () => {
|
|
const ip = '198.51.100.1'
|
|
const now = 1_000_000
|
|
assert.equal(botScore.isBanned(ip, now), false)
|
|
botScore.addScore(ip, botScore.BAN_THRESHOLD - 1, now)
|
|
assert.equal(botScore.isBanned(ip, now), false) // just under
|
|
botScore.addScore(ip, 1, now)
|
|
assert.equal(botScore.isBanned(ip, now), true) // at threshold
|
|
})
|
|
|
|
test('a single .env probe (weight 100) is an instant ban', () => {
|
|
const ip = '198.51.100.9'
|
|
const now = 5_000
|
|
botScore.addScore(ip, botScore.scoreForPath('/.env'), now)
|
|
assert.equal(botScore.isBanned(ip, now), true)
|
|
})
|
|
|
|
test('login failures + one scan hit ban faster than either alone', () => {
|
|
const ip = '198.51.100.2'
|
|
const now = 2_000_000
|
|
// Two failed logins alone: below threshold, not banned.
|
|
botScore.recordLoginFailure(ip, now)
|
|
botScore.recordLoginFailure(ip, now)
|
|
assert.equal(botScore.isBanned(ip, now), false)
|
|
// Add one medium scanner hit (wp-admin, 50) → crosses threshold.
|
|
botScore.addScore(ip, botScore.scoreForPath('/wp-admin'), now)
|
|
assert.equal(botScore.isBanned(ip, now), true)
|
|
})
|
|
|
|
test('score decays to zero after a quiet period', () => {
|
|
const ip = '198.51.100.3'
|
|
const t0 = 10_000
|
|
botScore.addScore(ip, 50, t0) // below threshold
|
|
// Long quiet gap, then another 50 — should NOT ban because the first decayed.
|
|
const later = t0 + botScore.QUIET_MS + 1
|
|
botScore.addScore(ip, 50, later)
|
|
assert.equal(botScore.isBanned(ip, later), false)
|
|
assert.equal(botScore._snapshot(ip).score, 50)
|
|
})
|
|
|
|
test('ban persists for the ban window and lifts after it', () => {
|
|
const ip = '198.51.100.4'
|
|
const now = 3_000_000
|
|
botScore.addScore(ip, botScore.BAN_THRESHOLD, now)
|
|
assert.equal(botScore.isBanned(ip, now + botScore.BAN_MS - 1), true)
|
|
assert.equal(botScore.isBanned(ip, now + botScore.BAN_MS + 1), false)
|
|
})
|
|
|
|
test('sweep removes stale unbanned entries but keeps banned and recent ones', () => {
|
|
const t0 = 100_000_000
|
|
// (a) stale + unbanned: scored below threshold, then goes quiet past QUIET_MS.
|
|
botScore.addScore('10.0.0.1', 50, t0)
|
|
// (b) banned + quiet: banned now, and its lastSeen is old at sweep time — must
|
|
// survive because the ban is still active.
|
|
botScore.addScore('10.0.0.2', botScore.BAN_THRESHOLD, t0)
|
|
// (c) recently active: scored just before the sweep, still inside QUIET_MS.
|
|
const sweepAt = t0 + botScore.QUIET_MS + 1
|
|
botScore.addScore('10.0.0.3', 50, sweepAt)
|
|
|
|
const removed = botScore.sweep(sweepAt)
|
|
|
|
assert.equal(removed, 1) // only the stale unbanned entry
|
|
assert.equal(botScore._snapshot('10.0.0.1'), null) // (a) evicted
|
|
assert.notEqual(botScore._snapshot('10.0.0.2'), null) // (b) banned → survives
|
|
assert.equal(botScore.isBanned('10.0.0.2', sweepAt), true)
|
|
assert.notEqual(botScore._snapshot('10.0.0.3'), null) // (c) recent → survives
|
|
})
|
|
|
|
test('sweep keeps an unbanned entry that is exactly at the quiet boundary', () => {
|
|
const t0 = 200_000_000
|
|
botScore.addScore('10.0.1.1', 40, t0)
|
|
// now - lastSeen === QUIET_MS (not strictly greater) → not yet evictable.
|
|
const removed = botScore.sweep(t0 + botScore.QUIET_MS)
|
|
assert.equal(removed, 0)
|
|
assert.notEqual(botScore._snapshot('10.0.1.1'), null)
|
|
})
|
|
|
|
test('guard: scanner junk path returns 404, legit path passes through', async () => {
|
|
const app = await startApp((a) => {
|
|
a.set('trust proxy', 1)
|
|
a.use(botScore.guard)
|
|
a.get('/', (req, res) => res.json({ ok: true }))
|
|
a.get('/admin', (req, res) => res.json({ ok: 'admin' }))
|
|
})
|
|
try {
|
|
const scan = await fetch(`${app.url}/wp-login.php`, { headers: { 'X-Forwarded-For': '203.0.113.20' } })
|
|
assert.equal(scan.status, 404)
|
|
|
|
const ok = await fetch(`${app.url}/admin`, { headers: { 'X-Forwarded-For': '203.0.113.21' } })
|
|
assert.equal(ok.status, 200)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('guard: once banned, an IP gets 404 on ALL routes', async () => {
|
|
const bannedIp = '203.0.113.30'
|
|
const app = await startApp((a) => {
|
|
a.set('trust proxy', 1)
|
|
a.use(botScore.guard)
|
|
a.get('/', (req, res) => res.json({ ok: true }))
|
|
})
|
|
try {
|
|
// One .env probe → instant ban for this IP.
|
|
const probe = await fetch(`${app.url}/.env`, { headers: { 'X-Forwarded-For': bannedIp } })
|
|
assert.equal(probe.status, 404)
|
|
|
|
// Now a normal path from the same IP is also 404.
|
|
const blocked = await fetch(`${app.url}/`, { headers: { 'X-Forwarded-For': bannedIp } })
|
|
assert.equal(blocked.status, 404)
|
|
|
|
// A different IP still gets through.
|
|
const other = await fetch(`${app.url}/`, { headers: { 'X-Forwarded-For': '203.0.113.31' } })
|
|
assert.equal(other.status, 200)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|