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:
96
server/test/loginProtection.test.js
Normal file
96
server/test/loginProtection.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const lp = require('../src/middleware/loginProtection')
|
||||
const { loginLimiter } = require('../src/middleware/rateLimit')
|
||||
const { startApp } = require('./_helper')
|
||||
|
||||
beforeEach(() => lp._reset())
|
||||
|
||||
test('recordFailure escalates the lockout exponentially', () => {
|
||||
const ip = '198.51.100.50'
|
||||
const now = 1_000_000
|
||||
const first = lp.recordFailure(ip, now)
|
||||
const second = lp.recordFailure(ip, now)
|
||||
const third = lp.recordFailure(ip, now)
|
||||
assert.equal(first, lp.BASE_MS) // 2^0
|
||||
assert.equal(second, lp.BASE_MS * 2) // 2^1
|
||||
assert.equal(third, lp.BASE_MS * 4) // 2^2
|
||||
})
|
||||
|
||||
test('lockout is capped at MAX_MS', () => {
|
||||
const ip = '198.51.100.51'
|
||||
const now = 1_000_000
|
||||
let last = 0
|
||||
for (let i = 0; i < 40; i++) last = lp.recordFailure(ip, now)
|
||||
assert.equal(last, lp.MAX_MS)
|
||||
})
|
||||
|
||||
test('retryAfterMs reflects the active lockout and clears after it elapses', () => {
|
||||
const ip = '198.51.100.52'
|
||||
const now = 2_000_000
|
||||
lp.recordFailure(ip, now) // locks BASE_MS
|
||||
assert.ok(lp.retryAfterMs(ip, now) > 0)
|
||||
assert.equal(lp.retryAfterMs(ip, now + lp.BASE_MS + 1), 0)
|
||||
})
|
||||
|
||||
test('recordSuccess clears the failure streak', () => {
|
||||
const ip = '198.51.100.53'
|
||||
const now = 2_000_000
|
||||
lp.recordFailure(ip, now)
|
||||
lp.recordSuccess(ip)
|
||||
assert.equal(lp.retryAfterMs(ip, now), 0)
|
||||
})
|
||||
|
||||
test('streak resets after a long quiet period (does not grow forever)', () => {
|
||||
const ip = '198.51.100.54'
|
||||
const t0 = 3_000_000
|
||||
lp.recordFailure(ip, t0)
|
||||
lp.recordFailure(ip, t0)
|
||||
// Come back after RESET_MS+ of quiet: next failure starts the streak over.
|
||||
const later = t0 + lp.RESET_MS + 1
|
||||
const delay = lp.recordFailure(ip, later)
|
||||
assert.equal(delay, lp.BASE_MS) // back to 2^0
|
||||
})
|
||||
|
||||
test('backoffGuard returns a generic 429 while locked out', async () => {
|
||||
// Pre-lock this IP, then confirm the guard blocks it with a generic message.
|
||||
const app = await startApp((a) => {
|
||||
a.set('trust proxy', 1)
|
||||
a.post('/login', lp.backoffGuard, (req, res) => res.json({ ok: true }))
|
||||
})
|
||||
try {
|
||||
lp.recordFailure('203.0.113.40') // lock the test client IP
|
||||
const res = await fetch(`${app.url}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Forwarded-For': '203.0.113.40' },
|
||||
})
|
||||
assert.equal(res.status, 429)
|
||||
const body = await res.json()
|
||||
assert.match(body.message, /too many login attempts/i)
|
||||
// Message must not reveal whether username or password was the problem.
|
||||
assert.doesNotMatch(body.message, /password|username/i)
|
||||
assert.ok(res.headers.get('retry-after'))
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('hard rate limiter caps attempts per IP (429 after the cap)', async () => {
|
||||
const app = await startApp((a) => {
|
||||
a.set('trust proxy', 1)
|
||||
a.post('/login', loginLimiter, (req, res) => res.json({ ok: true }))
|
||||
})
|
||||
try {
|
||||
const headers = { 'X-Forwarded-For': '203.0.113.41' }
|
||||
let sawLimit = false
|
||||
// Cap is 10/15min; the 11th should be blocked.
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const res = await fetch(`${app.url}/login`, { method: 'POST', headers })
|
||||
if (res.status === 429) sawLimit = true
|
||||
}
|
||||
assert.ok(sawLimit, 'expected the hard limiter to return 429 after the cap')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user