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>
90 lines
3.2 KiB
JavaScript
90 lines
3.2 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const { parseTrustProxy, applyTrustProxy } = require('../src/utils/trustProxy')
|
|
const { startApp } = require('./_helper')
|
|
|
|
test('parseTrustProxy: default (unset/empty) is a single hop', () => {
|
|
assert.equal(parseTrustProxy(''), 1)
|
|
assert.equal(parseTrustProxy(undefined), 1)
|
|
})
|
|
|
|
test('parseTrustProxy: integer hop count', () => {
|
|
assert.equal(parseTrustProxy('2'), 2)
|
|
assert.equal(parseTrustProxy('0'), 0)
|
|
})
|
|
|
|
test('parseTrustProxy: "false" disables proxy trust', () => {
|
|
assert.equal(parseTrustProxy('false'), false)
|
|
})
|
|
|
|
test('parseTrustProxy: blanket "true" is rejected and coerced to 1 (anti-spoof)', () => {
|
|
assert.equal(parseTrustProxy('true'), 1)
|
|
})
|
|
|
|
test('parseTrustProxy: CSV of IPs/CIDRs becomes an array; single stays a string', () => {
|
|
assert.deepEqual(parseTrustProxy('10.0.0.0/8, 172.18.0.1'), ['10.0.0.0/8', '172.18.0.1'])
|
|
assert.equal(parseTrustProxy('172.18.0.1'), '172.18.0.1')
|
|
})
|
|
|
|
test('applyTrustProxy: with 1 hop, req.ip reflects X-Forwarded-For client', async () => {
|
|
const app = await startApp((a) => {
|
|
applyTrustProxy(a, '1')
|
|
a.get('/ip', (req, res) => res.json({ ip: req.ip }))
|
|
})
|
|
try {
|
|
const res = await fetch(`${app.url}/ip`, { headers: { 'X-Forwarded-For': '203.0.113.7' } })
|
|
const body = await res.json()
|
|
assert.equal(body.ip, '203.0.113.7')
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('applyTrustProxy: pinned to the peer IP, XFF from that peer is trusted', async () => {
|
|
// Mirrors the production setup: TRUST_PROXY = ptero's LAN IP. Here the test
|
|
// client's peer address is loopback, so pin to loopback and confirm XFF wins.
|
|
const app = await startApp((a) => {
|
|
applyTrustProxy(a, '127.0.0.1')
|
|
a.get('/ip', (req, res) => res.json({ ip: req.ip }))
|
|
})
|
|
try {
|
|
const res = await fetch(`${app.url}/ip`, { headers: { 'X-Forwarded-For': '203.0.113.9' } })
|
|
const body = await res.json()
|
|
assert.equal(body.ip, '203.0.113.9')
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('applyTrustProxy: pinned to a DIFFERENT IP, XFF from this peer is NOT trusted', async () => {
|
|
// If ptero's IP is pinned but the connection comes from some other host, its
|
|
// X-Forwarded-For is ignored — nothing else on the LAN can spoof a client IP.
|
|
const app = await startApp((a) => {
|
|
applyTrustProxy(a, '10.11.12.13') // not the loopback peer this test connects from
|
|
a.get('/ip', (req, res) => res.json({ ip: req.ip }))
|
|
})
|
|
try {
|
|
const res = await fetch(`${app.url}/ip`, { headers: { 'X-Forwarded-For': '203.0.113.9' } })
|
|
const body = await res.json()
|
|
assert.notEqual(body.ip, '203.0.113.9')
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('applyTrustProxy: with false, a forged X-Forwarded-For is ignored', async () => {
|
|
const app = await startApp((a) => {
|
|
applyTrustProxy(a, 'false')
|
|
a.get('/ip', (req, res) => res.json({ ip: req.ip }))
|
|
})
|
|
try {
|
|
const res = await fetch(`${app.url}/ip`, { headers: { 'X-Forwarded-For': '203.0.113.7' } })
|
|
const body = await res.json()
|
|
// The spoofed client IP must NOT be trusted — req.ip stays the loopback peer.
|
|
assert.notEqual(body.ip, '203.0.113.7')
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|