From 870971fc1276a5baa302d3dc063e5ef0cb13f255 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 02:31:25 -0500 Subject: [PATCH] Add Bot Activity admin panel: banned-IP view + recent events + emergency unban MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 9 +- client/src/App.jsx | 2 + client/src/api/client.js | 2 + client/src/routes/admin/AdminLayout.jsx | 2 + .../routes/admin/views/BotActivityAdmin.jsx | 142 ++++++++++++++++++ server/src/middleware/botScore.js | 66 +++++++- server/src/router/v1/admin/admin.routes.js | 13 ++ .../router/v1/admin/botActivity.controller.js | 31 ++++ server/test/botScore.test.js | 80 ++++++++++ 9 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 client/src/routes/admin/views/BotActivityAdmin.jsx create mode 100644 server/src/router/v1/admin/botActivity.controller.js diff --git a/README.md b/README.md index 995c477..14dfce5 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ UOMSITE/ │ ├─ src/ │ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance │ │ ├─ routes/wiki/ Wiki landing + WikiArticle -│ │ ├─ routes/admin/ AdminLogin, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Users, Account) + editors +│ │ ├─ routes/admin/ AdminLogin, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Users, Account) + editors │ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, … │ │ ├─ contexts/ AuthContext, SiteContext │ │ ├─ api/client.js fetch wrapper (sends cookies) @@ -187,6 +187,7 @@ npm start # node server → serves API + SPA at http://localhost:3 | `/admin/wiki` | Wiki pages CRUD | | `/admin/settings` | Site settings | | `/admin/activity` | Activity log | +| `/admin/bot-activity` | Bot activity — banned IPs + recent scoring events, emergency unban (admin only) | | `/admin/users` | User management | | `/admin/account` | Account security (self-service TOTP two-factor) | @@ -198,7 +199,7 @@ npm start # node server → serves API + SPA at http://localhost:3 |---|---|---| | Auth | `/api/v1/auth` (`login`, `login/totp`, `logout`, `me`) | cookie | | Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none | -| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `users`, `account`, `account/totp/*`) | cookie (admin) | +| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `users`, `account`, `account/totp/*`) | cookie (admin) | Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`. See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract. @@ -254,7 +255,9 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`. - **Honeypot** field on the login form; submissions that fill it are treated as bots. - **Bot-scoring + automatic IP ban** — weighted scoring of CMS-scanner paths and junk 404s (with a periodic sweep of stale entries) bans hostile scanners; failed logins and honeypot hits feed the - score. + score. Admins get visibility into this on the **Bot Activity** panel: currently banned IPs and a + recent-events feed (in-memory, most-recent-first), plus a logged emergency **unban** for false + positives — read + unban only, not a scoring-config surface. **Uploads & input** diff --git a/client/src/App.jsx b/client/src/App.jsx index ce2e82c..5e2a0f1 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -26,6 +26,7 @@ import WikiAdmin from './routes/admin/views/WikiAdmin.jsx' import HeroEditor from './routes/admin/views/HeroEditor.jsx' import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' +import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' @@ -71,6 +72,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index bf49955..ae8194a 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -108,6 +108,8 @@ export const api = { getSettings: () => req('/admin/settings'), updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }), activity: (limit = 50) => req(`/admin/activity?limit=${limit}`), + botActivity: () => req('/admin/bot-activity'), + unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }), listUsers: () => req('/admin/users'), createUser: (data) => req('/admin/users', { method: 'POST', body: data }), updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }), diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index c6d69b7..7618320 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -11,6 +11,7 @@ const NAV = [ { to: '/admin/hero', label: 'Hero Editor' }, { to: '/admin/settings', label: 'Settings' }, { to: '/admin/activity', label: 'Activity' }, + { to: '/admin/bot-activity', label: 'Bot Activity' }, { to: '/admin/users', label: 'Users' }, { to: '/admin/account', label: 'Account' }, ] @@ -22,6 +23,7 @@ const TITLES = { '/admin/hero': 'Hero Editor', '/admin/settings': 'Site Settings', '/admin/activity': 'Activity Log', + '/admin/bot-activity': 'Bot Activity', '/admin/users': 'Users', '/admin/account': 'Account Security', } diff --git a/client/src/routes/admin/views/BotActivityAdmin.jsx b/client/src/routes/admin/views/BotActivityAdmin.jsx new file mode 100644 index 0000000..3ecadc7 --- /dev/null +++ b/client/src/routes/admin/views/BotActivityAdmin.jsx @@ -0,0 +1,142 @@ +import { useCallback, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useAsync } from '../../../lib/useAsync.js' +import { dateTime } from '../../../lib/format.js' +import { api } from '../../../api/client.js' + +// Read-only visibility into the botScore middleware: who is currently banned and +// a feed of recent scoring events. The only action is an emergency unban for +// false positives — there is no ban/adjust-weights surface here by design. +const mono = { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.82rem' } + +export default function BotActivityAdmin() { + const [tick, setTick] = useState(0) + const reload = useCallback(() => setTick((t) => t + 1), []) + const { loading, error, data } = useAsync(() => api.admin.botActivity(), [tick]) + const [busyIp, setBusyIp] = useState('') + + const ips = data?.ips || [] + const events = data?.events || [] + const banned = ips.filter((e) => e.banned) + + async function unban(ip) { + if (!window.confirm(`Unban ${ip}? This clears its score and ban immediately.`)) return + setBusyIp(ip) + try { + await api.admin.unbanIp(ip) + reload() + } catch { + // Surface nothing intrusive; a reload will re-fetch true state either way. + reload() + } finally { + setBusyIp('') + } + } + + if (loading) return + if (error) return + + return ( +
+

+ Live, in-memory scoring and ban state from the bot-protection middleware. State resets + when the server restarts. +

+ + {/* Currently banned IPs */} +
+

+ Currently banned{banned.length > 0 ? ` (${banned.length})` : ''} +

+
+ + + + + + + + + + {banned.length === 0 && ( + + + + )} + {banned.map((e) => ( + + + + + + + ))} + +
IPScoreBanned until +
+ No IPs are currently banned. +
+ {e.ip} + {e.score}{dateTime(e.bannedUntil)} + +
+
+
+ + {/* Recent scoring events */} +
+

+ Recent events +

+
+ + + + + + + + + + + + + {events.length === 0 && ( + + + + )} + {events.map((ev, i) => ( + + + + + + + + + ))} + +
WhenIPReasonPathPointsScore
+ No events recorded yet. +
{dateTime(ev.ts)} + {ev.ip} + + + {ev.reason} + + + {ev.path || '—'} + {ev.points ? `+${ev.points}` : '—'}{ev.score}
+
+
+
+ ) +} diff --git a/server/src/middleware/botScore.js b/server/src/middleware/botScore.js index 259261b..2327d80 100644 --- a/server/src/middleware/botScore.js +++ b/server/src/middleware/botScore.js @@ -72,6 +72,28 @@ const PATH_WEIGHTS = [ // ip -> { score, lastSeen, bannedUntil } const store = new Map() +// ── Recent-events ring buffer ──────────────────────────────────────────────── +// A bounded, most-recent-first log of notable events (scanner hit, ban, honeypot, +// login failure) so admins can see recent activity without tailing container +// logs. In-memory only, matching the store — not persisted. Oldest entries fall +// off once EVENT_CAP is reached. Each event: { ts, ip, type, path, points, +// score, reason }. +const EVENT_CAP = 300 +const events = [] + +function recordEvent(evt) { + events.push(evt) + if (events.length > EVENT_CAP) events.shift() +} + +// Most-recent-first slice of the event buffer (default: whole buffer, capped). +function recentEvents(limit = EVENT_CAP) { + const n = Math.min(limit, events.length) + const out = new Array(n) + for (let i = 0; i < n; i++) out[i] = events[events.length - 1 - i] + return out +} + // Return the scanner weight for a request path (0 if it is a legitimate path). function scoreForPath(pathname) { const p = String(pathname || '').toLowerCase() @@ -97,7 +119,10 @@ function isBanned(ip, now = Date.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') { +// +// `path` is the request path when the points came from a scanned URL (else null), +// recorded into the event buffer alongside the resulting score. +function addScore(ip, points, now = Date.now(), reason = 'scan', path = null) { 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. @@ -106,10 +131,17 @@ function addScore(ip, points, now = Date.now(), reason = 'scan') { } e.score += points e.lastSeen = now - if (e.score >= BAN_THRESHOLD && e.bannedUntil <= now) { + const justBanned = e.score >= BAN_THRESHOLD && e.bannedUntil <= now + if (justBanned) { e.bannedUntil = now + BAN_MS log.warn('IP banned', { ip, score: e.score, reason, banMs: BAN_MS }) } + // Record the scoring event, then a distinct ban event if this hit crossed the + // threshold — so the feed shows both "why" (the hit) and the resulting ban. + recordEvent({ ts: now, ip, type: reason, path, points, score: e.score, reason }) + if (justBanned) { + recordEvent({ ts: now, ip, type: 'ban', path, points: 0, score: e.score, reason }) + } return e } @@ -136,7 +168,7 @@ function guard(req, res, next) { // 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') + const e = addScore(ip, points, now, 'scan', req.path) log.warn('scanner path hit', { ip, path: req.path, points, score: e.score }) return notFound(res) } @@ -197,9 +229,34 @@ function stopSweeper() { // Start sweeping on load — this is a single long-lived process. startSweeper() +// Snapshot of every IP currently in the store, for the admin view: score, ban +// state, when the ban lifts, and last-seen. Most-recently-seen first. +function listState(now = Date.now()) { + const out = [] + for (const [ip, e] of store) { + out.push({ + ip, + score: e.score, + banned: e.bannedUntil > now, + bannedUntil: e.bannedUntil || 0, + lastSeen: e.lastSeen || 0, + }) + } + out.sort((a, b) => b.lastSeen - a.lastSeen) + return out +} + +// Manually clear a single IP's entry (admin emergency unban / false positive). +// Fully removes it from the store, so it is neither banned nor carrying score. +// Returns true if an entry existed and was removed. +function unban(ip) { + return store.delete(ip) +} + // Test/ops helpers. function _reset() { store.clear() + events.length = 0 } function _snapshot(ip) { const e = store.get(ip) @@ -213,6 +270,9 @@ module.exports = { isBanned, recordLoginFailure, recordHoneypot, + recentEvents, + listState, + unban, sweep, startSweeper, stopSweeper, diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 988d957..39475f0 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -7,6 +7,7 @@ const { body, param } = require('express-validator') const ctrl = require('./admin.controller') const account = require('./account.controller') +const botActivity = require('./botActivity.controller') const { isLoggedIn, requireRole } = require('../../../utils/auth') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') @@ -178,6 +179,18 @@ adminRouter.put('/settings', adminOnly, ctrl.updateSettings) // ── Activity log ────────────────────────────────────────────────────── adminRouter.get('/activity', ctrl.listActivity) +// ── Bot activity (admin only) ───────────────────────────────────────── +// Read-only view of the botScore middleware's in-memory scoring/ban state and +// recent events, plus an emergency unban for false positives. +adminRouter.get('/bot-activity', adminOnly, botActivity.getBotActivity) +adminRouter.post( + '/bot-activity/unban', + adminOnly, + body('ip').isIP(), + validate, + botActivity.unbanIp, +) + // ── User management (admin only) ────────────────────────────────────── adminRouter.use('/users', adminOnly) adminRouter.get('/users', ctrl.listUsers) diff --git a/server/src/router/v1/admin/botActivity.controller.js b/server/src/router/v1/admin/botActivity.controller.js new file mode 100644 index 0000000..39a593e --- /dev/null +++ b/server/src/router/v1/admin/botActivity.controller.js @@ -0,0 +1,31 @@ +// Bot-scoring / IP-ban visibility for admins. Read-only view of the botScore +// middleware's in-memory state plus a recent-events feed, and a single mutating +// action — an emergency unban for false positives. Mounted behind the admin-only +// RBAC gate (see admin.routes.js). This is visibility + emergency unban only; +// there is deliberately no way to add a ban or change scoring weights from here. + +const botScore = require('../../../middleware/botScore') +const activity = require('../../../model/activity/activity.model') + +const log = require('../../../utils/logger')('botactivity') + +// Current store state (all scored IPs, banned or not) plus the recent-events +// buffer, most-recent-first. Both are in-memory and reset on process restart. +async function getBotActivity(req, res) { + return res.json({ + ips: botScore.listState(), + events: botScore.recentEvents(), + }) +} + +// Emergency unban: clear a single IP's entry so it is no longer banned or +// carrying score. A real administrative action — logged with the admin user. +async function unbanIp(req, res) { + const ip = req.body.ip + const removed = botScore.unban(ip) + await activity.log({ req, action: 'botscore.unban', detail: { ip, removed } }) + log.info('IP unbanned by admin', { ip, admin: req.user.username, removed }) + return res.json({ ip, removed }) +} + +module.exports = { getBotActivity, unbanIp } diff --git a/server/test/botScore.test.js b/server/test/botScore.test.js index ac5d6a7..8c90fff 100644 --- a/server/test/botScore.test.js +++ b/server/test/botScore.test.js @@ -167,6 +167,86 @@ test('guard: scanner junk path returns 404, legit path passes through', async () } }) +// ── 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) => {