Admin login hardening: RBAC-safe controls, optional TOTP, bot-scoring + IP ban (closes #9) #19
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/admin-login-hardening"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #9.
Layered hardening around the admin login and the app edge. Backend + a small amount of admin-UI. Config is env-driven; no secrets committed.
1. Trust proxy (
server/src/utils/trustProxy.js)Request path is
client → Pangolin → newt agent "ptero" (separate VM) → app. ptero is the peer that connects to us, soTRUST_PROXYis pinned to ptero's LAN IP (set in prod.env; a static Omada reservation exists). Express then trustsX-Forwarded-Foronly from ptero — nothing else on the LAN can spoof a client IP to dodge rate limits/bans. A blankettrueis intentionally rejected (coerced to 1) with a warning.DEBUG_TRUST_PROXY=1logs raw peer address + XFF + resolvedreq.ipper request so the proxy IP can be re-verified without a redeploy. Applied before any middleware that readsreq.ip.2. Login throttling (
server/src/middleware/loginProtection.js)3. Honeypot (login form +
auth.controller)Hidden, plausibly-named field (
company), off-screen via CSS position/opacity (notdisplay:none/hidden, which bots skip). A filled value fails generically and is scored as an unambiguous bot.4. Optional per-user TOTP 2FA (speakeasy/qrcode)
totp_secret/totp_enabledcolumns + idempotent migration; secrets stripped from all user-facing responses.stage:'totp', explicitly not a session —getUserFromRequestrejects stage-tagged tokens) is required before the real session cookie is issued. Opt-in per user; password-only accounts are unaffected.5. Bot / scanner scoring + IP ban (
server/src/middleware/botScore.js)/wp-admin/install.phpis the single highest-weighted entry (top offender in prod Pangolin logs) — an effective 1-hit ban; other paths keep their weights./adminlogin from credential stuffing, not to stop the scanning itself. Failed logins and honeypot hits feed the same score. All blocks are 404, never 403 (never confirm a path or a ban).unref'd)setIntervalevicts entries that are not banned and quiet longer thanQUIET_MS, so the in-memory store can't grow unbounded; banned/recent entries are kept, and the interval is cleared on graceful shutdown.Tests (
node --test, 40 passing, clean exit)Trust-proxy parsing + live
req.ipincl. pinned-IP (trusts XFF from the pinned peer, ignores it from any other host); rate limiter cap + exponential backoff; honeypot rejection (generic 401, IP scored); TOTP verify enabled/disabled + "challenge token is not a session"; bot-score threshold/decay/ban + junk-404-is-ban-independent + install.php highest-weight/first-seen-IP-404 + store sweep (stale-unbanned removed, banned survives, recent survives).npm testwas also fixed to exit cleanly (DB-touching test files use a closed port + release the pool).Verified
npm run build) ✔ · all server filesnode -c✔ · full suite 40/40 ✔./admin/loginworks; honeypot and bad-creds return an identical generic 401; TOTP enroll → QR → enable → two-step login verified end-to-end.Notes
main(includes the earlier #10–#13 fixes). New env vars documented in both.env.examplefiles:TRUST_PROXY,DEBUG_TRUST_PROXY,TOTP_ISSUER,TOTP_CHALLENGE_TTL.TRUST_PROXYto ptero's LAN IP in the production.envbefore deploy.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>