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>
77 lines
2.5 KiB
JavaScript
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)
|
|
})
|