Files
website/server/test/honeypot.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

77 lines
2.5 KiB
JavaScript

// Point the DB at a closed port BEFORE requiring anything that builds the pool.
// The only code path here that reaches the database (the empty-honeypot case →
// username lookup) then fails fast with ECONNREFUSED instead of opening a real
// pooled connection that would keep this test process alive and hang the runner.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const authCtrl = require('../src/router/v1/auth/auth.controller')
const botScore = require('../src/middleware/botScore')
const lp = require('../src/middleware/loginProtection')
const db = require('../src/utils/db')
// Release the DB pool so the process can exit cleanly even if a connection was
// created during module load.
after(() => db.close())
// Minimal res double capturing status/json; set() is a no-op for headers.
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
set() {
return this
},
}
}
beforeEach(() => {
botScore._reset()
lp._reset()
})
test('honeypot field name matches what the client renders', () => {
assert.equal(authCtrl.HONEYPOT_FIELD, 'company')
})
test('a filled honeypot fails generically and bans the IP', async () => {
const ip = '203.0.113.70'
const req = {
ip,
body: { username: 'admin', password: 'whatever', [authCtrl.HONEYPOT_FIELD]: 'Acme Corp' },
}
const res = mockRes()
await authCtrl.login(req, res)
// Generic failure — never says the honeypot was the reason.
assert.equal(res.statusCode, 401)
assert.match(res.body.message, /incorrect username or password/i)
assert.doesNotMatch(res.body.message, /honeypot|bot|company/i)
// Scored as an unambiguous bot: instant ban + backoff started.
assert.equal(botScore.isBanned(ip), true)
assert.ok(lp.retryAfterMs(ip) > 0)
})
test('an empty honeypot does NOT trigger bot scoring (branch not taken)', async () => {
const ip = '203.0.113.71'
const req = { ip, body: { username: 'admin', password: 'whatever', [authCtrl.HONEYPOT_FIELD]: '' } }
const res = mockRes()
// With an empty honeypot the code proceeds to the DB lookup, which has no
// connection in this unit test and is caught → 500. The point of this test is
// only that the honeypot branch did not fire, so the IP is not banned.
await authCtrl.login(req, res)
assert.equal(botScore.isBanned(ip), false)
})