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>
This commit is contained in:
@@ -61,7 +61,7 @@ UOMSITE/
|
|||||||
│ ├─ src/
|
│ ├─ src/
|
||||||
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
|
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
|
||||||
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
|
│ │ ├─ 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, …
|
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, …
|
||||||
│ │ ├─ contexts/ AuthContext, SiteContext
|
│ │ ├─ contexts/ AuthContext, SiteContext
|
||||||
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
│ │ ├─ 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/wiki` | Wiki pages CRUD |
|
||||||
| `/admin/settings` | Site settings |
|
| `/admin/settings` | Site settings |
|
||||||
| `/admin/activity` | Activity log |
|
| `/admin/activity` | Activity log |
|
||||||
|
| `/admin/bot-activity` | Bot activity — banned IPs + recent scoring events, emergency unban (admin only) |
|
||||||
| `/admin/users` | User management |
|
| `/admin/users` | User management |
|
||||||
| `/admin/account` | Account security (self-service TOTP two-factor) |
|
| `/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 |
|
| 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 |
|
| 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`.
|
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract.
|
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.
|
- **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
|
- **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
|
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**
|
**Uploads & input**
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
|||||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.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 UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ export default function App() {
|
|||||||
<Route path="hero" element={<HeroEditor />} />
|
<Route path="hero" element={<HeroEditor />} />
|
||||||
<Route path="settings" element={<SettingsAdmin />} />
|
<Route path="settings" element={<SettingsAdmin />} />
|
||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
<Route path="account" element={<AccountAdmin />} />
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export const api = {
|
|||||||
getSettings: () => req('/admin/settings'),
|
getSettings: () => req('/admin/settings'),
|
||||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
||||||
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
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'),
|
listUsers: () => req('/admin/users'),
|
||||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const NAV = [
|
|||||||
{ to: '/admin/hero', label: 'Hero Editor' },
|
{ to: '/admin/hero', label: 'Hero Editor' },
|
||||||
{ to: '/admin/settings', label: 'Settings' },
|
{ to: '/admin/settings', label: 'Settings' },
|
||||||
{ to: '/admin/activity', label: 'Activity' },
|
{ to: '/admin/activity', label: 'Activity' },
|
||||||
|
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
||||||
{ to: '/admin/users', label: 'Users' },
|
{ to: '/admin/users', label: 'Users' },
|
||||||
{ to: '/admin/account', label: 'Account' },
|
{ to: '/admin/account', label: 'Account' },
|
||||||
]
|
]
|
||||||
@@ -22,6 +23,7 @@ const TITLES = {
|
|||||||
'/admin/hero': 'Hero Editor',
|
'/admin/hero': 'Hero Editor',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
|
'/admin/bot-activity': 'Bot Activity',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
'/admin/account': 'Account Security',
|
'/admin/account': 'Account Security',
|
||||||
}
|
}
|
||||||
|
|||||||
142
client/src/routes/admin/views/BotActivityAdmin.jsx
Normal file
142
client/src/routes/admin/views/BotActivityAdmin.jsx
Normal file
@@ -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 <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load bot activity." />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>
|
||||||
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
|
Live, in-memory scoring and ban state from the bot-protection middleware. State resets
|
||||||
|
when the server restarts.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Currently banned IPs */}
|
||||||
|
<div>
|
||||||
|
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.1rem', color: 'var(--head)' }}>
|
||||||
|
Currently banned{banned.length > 0 ? ` (${banned.length})` : ''}
|
||||||
|
</h2>
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">IP</th>
|
||||||
|
<th className="adm-th">Score</th>
|
||||||
|
<th className="adm-th">Banned until</th>
|
||||||
|
<th className="adm-th" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{banned.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>
|
||||||
|
No IPs are currently banned.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{banned.map((e) => (
|
||||||
|
<tr key={e.ip}>
|
||||||
|
<td className="adm-td" style={{ ...mono, color: 'var(--head)' }}>
|
||||||
|
{e.ip}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td">{e.score}</td>
|
||||||
|
<td className="adm-td dim">{dateTime(e.bannedUntil)}</td>
|
||||||
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => unban(e.ip)}
|
||||||
|
disabled={busyIp === e.ip}
|
||||||
|
className="btn btn-sq"
|
||||||
|
style={{ borderColor: '#d98b84', color: '#d98b84', padding: '5px 12px', fontSize: '0.82rem' }}
|
||||||
|
>
|
||||||
|
{busyIp === e.ip ? 'Unbanning…' : 'Unban'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent scoring events */}
|
||||||
|
<div>
|
||||||
|
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.1rem', color: 'var(--head)' }}>
|
||||||
|
Recent events
|
||||||
|
</h2>
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
<th className="adm-th">IP</th>
|
||||||
|
<th className="adm-th">Reason</th>
|
||||||
|
<th className="adm-th">Path</th>
|
||||||
|
<th className="adm-th">Points</th>
|
||||||
|
<th className="adm-th">Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>
|
||||||
|
No events recorded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{events.map((ev, i) => (
|
||||||
|
<tr key={`${ev.ts}-${ev.ip}-${i}`}>
|
||||||
|
<td className="adm-td dim">{dateTime(ev.ts)}</td>
|
||||||
|
<td className="adm-td" style={{ ...mono, color: 'var(--text)' }}>
|
||||||
|
{ev.ip}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
<span style={{ ...mono, color: ev.type === 'ban' ? '#d98b84' : 'var(--accent)' }}>
|
||||||
|
{ev.reason}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim" style={{ ...mono, wordBreak: 'break-all' }}>
|
||||||
|
{ev.path || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim">{ev.points ? `+${ev.points}` : '—'}</td>
|
||||||
|
<td className="adm-td">{ev.score}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -72,6 +72,28 @@ const PATH_WEIGHTS = [
|
|||||||
// ip -> { score, lastSeen, bannedUntil }
|
// ip -> { score, lastSeen, bannedUntil }
|
||||||
const store = new Map()
|
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).
|
// Return the scanner weight for a request path (0 if it is a legitimate path).
|
||||||
function scoreForPath(pathname) {
|
function scoreForPath(pathname) {
|
||||||
const p = String(pathname || '').toLowerCase()
|
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
|
// 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.
|
// 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)
|
const e = getEntry(ip)
|
||||||
// Decay: if the IP has been quiet longer than QUIET_MS (and is not currently
|
// 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.
|
// 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.score += points
|
||||||
e.lastSeen = now
|
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
|
e.bannedUntil = now + BAN_MS
|
||||||
log.warn('IP banned', { ip, score: e.score, reason, banMs: 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
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +168,7 @@ function guard(req, res, next) {
|
|||||||
// if the IP is reused), but the 404 does not depend on it.
|
// if the IP is reused), but the 404 does not depend on it.
|
||||||
const points = scoreForPath(req.path)
|
const points = scoreForPath(req.path)
|
||||||
if (points > 0) {
|
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 })
|
log.warn('scanner path hit', { ip, path: req.path, points, score: e.score })
|
||||||
return notFound(res)
|
return notFound(res)
|
||||||
}
|
}
|
||||||
@@ -197,9 +229,34 @@ function stopSweeper() {
|
|||||||
// Start sweeping on load — this is a single long-lived process.
|
// Start sweeping on load — this is a single long-lived process.
|
||||||
startSweeper()
|
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.
|
// Test/ops helpers.
|
||||||
function _reset() {
|
function _reset() {
|
||||||
store.clear()
|
store.clear()
|
||||||
|
events.length = 0
|
||||||
}
|
}
|
||||||
function _snapshot(ip) {
|
function _snapshot(ip) {
|
||||||
const e = store.get(ip)
|
const e = store.get(ip)
|
||||||
@@ -213,6 +270,9 @@ module.exports = {
|
|||||||
isBanned,
|
isBanned,
|
||||||
recordLoginFailure,
|
recordLoginFailure,
|
||||||
recordHoneypot,
|
recordHoneypot,
|
||||||
|
recentEvents,
|
||||||
|
listState,
|
||||||
|
unban,
|
||||||
sweep,
|
sweep,
|
||||||
startSweeper,
|
startSweeper,
|
||||||
stopSweeper,
|
stopSweeper,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { body, param } = require('express-validator')
|
|||||||
|
|
||||||
const ctrl = require('./admin.controller')
|
const ctrl = require('./admin.controller')
|
||||||
const account = require('./account.controller')
|
const account = require('./account.controller')
|
||||||
|
const botActivity = require('./botActivity.controller')
|
||||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||||
const noindex = require('../../../middleware/noindex')
|
const noindex = require('../../../middleware/noindex')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
@@ -178,6 +179,18 @@ adminRouter.put('/settings', adminOnly, ctrl.updateSettings)
|
|||||||
// ── Activity log ──────────────────────────────────────────────────────
|
// ── Activity log ──────────────────────────────────────────────────────
|
||||||
adminRouter.get('/activity', ctrl.listActivity)
|
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) ──────────────────────────────────────
|
// ── User management (admin only) ──────────────────────────────────────
|
||||||
adminRouter.use('/users', adminOnly)
|
adminRouter.use('/users', adminOnly)
|
||||||
adminRouter.get('/users', ctrl.listUsers)
|
adminRouter.get('/users', ctrl.listUsers)
|
||||||
|
|||||||
31
server/src/router/v1/admin/botActivity.controller.js
Normal file
31
server/src/router/v1/admin/botActivity.controller.js
Normal file
@@ -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 }
|
||||||
@@ -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 () => {
|
test('guard: once banned, an IP gets 404 on ALL routes', async () => {
|
||||||
const bannedIp = '203.0.113.30'
|
const bannedIp = '203.0.113.30'
|
||||||
const app = await startApp((a) => {
|
const app = await startApp((a) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user