Files
website/server/test/botScore.test.js
Claude 870971fc12 Add Bot Activity admin panel: banned-IP view + recent events + emergency unban
Expose the botScore middleware's in-memory scoring/ban state to admins.
Previously state lived only in the store Map with no persistence or API — the
only visibility was tailing container logs.

- botScore: bounded ring buffer (300) recording scan/login-fail/honeypot and
  ban events (most-recent-first); listState() snapshot of all scored IPs;
  unban() to clear a single IP.
- New admin-only endpoints GET /admin/bot-activity and
  POST /admin/bot-activity/unban (RBAC admin gate, IP validated). Unban is
  activity-logged with the admin username.
- Bot Activity tab: currently-banned table with Unban, plus a recent-events
  feed, following the existing admin table patterns.
- Tests for the buffer, listState, and unban (guard lets an unbanned IP back
  through). README updated.

Read + emergency-unban only — no ban-add or weight-editing surface. Buffer is
in-memory, matching the store; not persisted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 02:31:25 -05:00

273 lines
11 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()
}
})
// ── Recent-events buffer, state snapshot, and unban ─────────────────────────
test('recentEvents records scoring events most-recent-first, with a ban event', () => {
const ip = '198.51.100.20'
const now = 4_000_000
botScore.addScore(ip, 50, now, 'scan', '/wp-admin') // below threshold
botScore.addScore(ip, 50, now, 'scan', '/wp-content') // crosses → ban
const events = botScore.recentEvents()
// Most-recent-first: the ban event (recorded last) is at the front, then the
// second scan, then the first scan.
assert.equal(events[0].type, 'ban')
assert.equal(events[0].ip, ip)
assert.equal(events[0].score, 100)
assert.equal(events[1].type, 'scan')
assert.equal(events[1].path, '/wp-content')
assert.equal(events[1].points, 50)
assert.equal(events[2].path, '/wp-admin')
// login-fail / honeypot reasons are captured too.
botScore.recordLoginFailure('198.51.100.21', now)
assert.equal(botScore.recentEvents()[0].reason, 'login-fail')
})
test('recentEvents is bounded (oldest events fall off)', () => {
const now = 4_100_000
// Push well past the cap from many distinct IPs (each hit is one event).
for (let i = 0; i < 400; i++) {
botScore.addScore(`10.9.${Math.floor(i / 256)}.${i % 256}`, 10, now, 'scan', '/x')
}
const events = botScore.recentEvents()
assert.ok(events.length <= 300, `buffer should be capped, got ${events.length}`)
})
test('listState reports every stored IP with its ban state', () => {
const now = 4_200_000
botScore.addScore('198.51.100.30', 40, now) // scored, not banned
botScore.addScore('198.51.100.31', botScore.BAN_THRESHOLD, now) // banned
const state = botScore.listState(now)
const byIp = Object.fromEntries(state.map((s) => [s.ip, s]))
assert.equal(byIp['198.51.100.30'].banned, false)
assert.equal(byIp['198.51.100.30'].score, 40)
assert.equal(byIp['198.51.100.31'].banned, true)
assert.ok(byIp['198.51.100.31'].bannedUntil > now)
})
test('unban clears an IP entry and lifts its ban', () => {
const ip = '198.51.100.40'
const now = 4_300_000
botScore.addScore(ip, botScore.BAN_THRESHOLD, now)
assert.equal(botScore.isBanned(ip, now), true)
assert.equal(botScore.unban(ip), true) // existed → removed
assert.equal(botScore.isBanned(ip, now), false)
assert.equal(botScore._snapshot(ip), null)
// Unbanning an unknown IP is a no-op returning false.
assert.equal(botScore.unban('198.51.100.99'), false)
})
test('guard: an unbanned IP can reach normal routes again', async () => {
const ip = '203.0.113.50'
const app = await startApp((a) => {
a.set('trust proxy', 1)
a.use(botScore.guard)
a.get('/', (req, res) => res.json({ ok: true }))
})
try {
await fetch(`${app.url}/.env`, { headers: { 'X-Forwarded-For': ip } }) // instant ban
const blocked = await fetch(`${app.url}/`, { headers: { 'X-Forwarded-For': ip } })
assert.equal(blocked.status, 404)
botScore.unban(ip) // admin clears the false positive
const ok = await fetch(`${app.url}/`, { headers: { 'X-Forwarded-For': ip } })
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()
}
})