Files
website/server/test/loginProtection.test.js
Claude d38c98ad9e 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>
2026-07-02 23:22:35 -05:00

97 lines
3.2 KiB
JavaScript

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()
}
})