Admin login hardening: RBAC-safe controls, optional TOTP, bot-scoring + IP ban (closes #9) #19

Merged
whitlocktech merged 1 commits from feature/admin-login-hardening into main 2026-07-03 04:27:52 +00:00
Member

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, so TRUST_PROXY is pinned to ptero's LAN IP (set in prod .env; a static Omada reservation exists). Express then trusts X-Forwarded-For only from ptero — nothing else on the LAN can spoof a client IP to dodge rate limits/bans. A blanket true is intentionally rejected (coerced to 1) with a warning. DEBUG_TRUST_PROXY=1 logs raw peer address + XFF + resolved req.ip per request so the proxy IP can be re-verified without a redeploy. Applied before any middleware that reads req.ip.

2. Login throttling (server/src/middleware/loginProtection.js)

  • express-slow-down progressive delay, then the existing hard rate cap (10/15 min), plus a separate per-IP exponential backoff tracked independently of the rate limiter so it persists across the window reset.
  • Every failure (bad user, bad pass, honeypot, backoff) returns one generic message — never reveals which was wrong.

3. Honeypot (login form + auth.controller)

Hidden, plausibly-named field (company), off-screen via CSS position/opacity (not display: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_enabled columns + idempotent migration; secrets stripped from all user-facing responses.
  • Self-service Account page: enroll via QR → confirm a code to enable; disable requires a current code.
  • Login is two-step only for enrolled users: after the password, a short-lived signed challenge (stage:'totp', explicitly not a session — getUserFromRequest rejects 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)

  • Weighted CMS-scanner paths (this app runs none of them). Junk paths 404 FIRST, unconditionally — evaluated before any ban check, so a scanner rotating through fresh Cloudflare edge IPs (104.23.x, 162.158.x, 172.68-71.x) gets no free pass while its score warms up.
  • /wp-admin/install.php is the single highest-weighted entry (top offender in prod Pangolin logs) — an effective 1-hit ban; other paths keep their weights.
  • Per-IP score with quiet-period decay temp-bans an IP from all routes for 1h once past a deliberately low threshold (biased low because fresh IPs are cheap for this actor). The ban exists to protect the real /admin login 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).
  • Cleanup sweep: a periodic (unref'd) setInterval evicts entries that are not banned and quiet longer than QUIET_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.ip incl. 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 test was also fixed to exit cleanly (DB-touching test files use a closed port + release the pool).

Verified

  • Client build (npm run build) ✔ · all server files node -c ✔ · full suite 40/40 ✔.
  • Ran locally (DB :3307 / API :3000 / Vite :5173): scanner paths & banned IPs 404; real /admin/login works; honeypot and bad-creds return an identical generic 401; TOTP enroll → QR → enable → two-step login verified end-to-end.

Notes

  • Branched from current main (includes the earlier #10–#13 fixes). New env vars documented in both .env.example files: TRUST_PROXY, DEBUG_TRUST_PROXY, TOTP_ISSUER, TOTP_CHALLENGE_TTL.
  • Set TRUST_PROXY to ptero's LAN IP in the production .env before deploy.
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, so `TRUST_PROXY` is pinned to **ptero's LAN IP** (set in prod `.env`; a static Omada reservation exists). Express then trusts `X-Forwarded-For` **only** from ptero — nothing else on the LAN can spoof a client IP to dodge rate limits/bans. A blanket `true` is intentionally **rejected (coerced to 1)** with a warning. `DEBUG_TRUST_PROXY=1` logs raw peer address + XFF + resolved `req.ip` per request so the proxy IP can be re-verified without a redeploy. Applied before any middleware that reads `req.ip`. ## 2. Login throttling (`server/src/middleware/loginProtection.js`) - **express-slow-down** progressive delay, then the existing **hard rate cap** (10/15 min), plus a **separate per-IP exponential backoff** tracked independently of the rate limiter so it persists across the window reset. - Every failure (bad user, bad pass, honeypot, backoff) returns **one generic message** — never reveals which was wrong. ## 3. Honeypot (login form + `auth.controller`) Hidden, plausibly-named field (`company`), off-screen via CSS position/opacity (not `display: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_enabled` columns + idempotent migration; secrets stripped from all user-facing responses. - Self-service **Account** page: enroll via QR → confirm a code to enable; disable requires a current code. - Login is **two-step only for enrolled users**: after the password, a short-lived **signed challenge** (`stage:'totp'`, explicitly *not* a session — `getUserFromRequest` rejects 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`) - Weighted CMS-scanner paths (this app runs none of them). **Junk paths 404 FIRST, unconditionally** — evaluated before any ban check, so a scanner rotating through fresh Cloudflare edge IPs (104.23.x, 162.158.x, 172.68-71.x) gets **no free pass** while its score warms up. - `/wp-admin/install.php` is the **single highest-weighted** entry (top offender in prod Pangolin logs) — an effective 1-hit ban; other paths keep their weights. - Per-IP score with quiet-period decay temp-bans an IP from **all** routes for 1h once past a **deliberately low threshold** (biased low because fresh IPs are cheap for this actor). The ban exists to protect the real `/admin` login 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). - **Cleanup sweep**: a periodic (`unref`'d) `setInterval` evicts entries that are not banned and quiet longer than `QUIET_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.ip` incl. 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 test` was also fixed to exit cleanly (DB-touching test files use a closed port + release the pool). ## Verified - Client build (`npm run build`) ✔ · all server files `node -c` ✔ · full suite 40/40 ✔. - Ran locally (DB :3307 / API :3000 / Vite :5173): scanner paths & banned IPs 404; real `/admin/login` works; honeypot and bad-creds return an identical generic 401; TOTP enroll → QR → enable → two-step login verified end-to-end. ## Notes - Branched from current `main` (includes the earlier #10–#13 fixes). New env vars documented in both `.env.example` files: `TRUST_PROXY`, `DEBUG_TRUST_PROXY`, `TOTP_ISSUER`, `TOTP_CHALLENGE_TTL`. - Set `TRUST_PROXY` to ptero's LAN IP in the production `.env` before deploy.
wtclaude added 1 commit 2026-07-03 04:23:23 +00:00
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>
whitlocktech approved these changes 2026-07-03 04:27:46 +00:00
whitlocktech merged commit ea46b5d346 into main 2026-07-03 04:27:52 +00:00
Sign in to join this conversation.
No description provided.