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>
This commit is contained in:
@@ -7,6 +7,8 @@ const users = require('../model/users/users.model')
|
||||
const JWT_SECRET = process.env.JWT_SECRET
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
|
||||
const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token'
|
||||
// Lifetime of the short-lived "password verified, awaiting TOTP" token.
|
||||
const TOTP_CHALLENGE_TTL = process.env.TOTP_CHALLENGE_TTL || '5m'
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
log.warn('JWT_SECRET is not set — set it in .env before going to production')
|
||||
@@ -25,6 +27,19 @@ function verifyToken(token) {
|
||||
}
|
||||
}
|
||||
|
||||
// Short-lived token issued after the password step for users with TOTP enabled.
|
||||
// It is NOT a session: it carries stage:'totp' so getUserFromRequest rejects it,
|
||||
// and it is only accepted by verifyTotpChallenge to gate the second factor.
|
||||
function signTotpChallenge(user) {
|
||||
return jwt.sign({ id: user.id, stage: 'totp' }, JWT_SECRET, { expiresIn: TOTP_CHALLENGE_TTL })
|
||||
}
|
||||
|
||||
function verifyTotpChallenge(token) {
|
||||
const decoded = verifyToken(token)
|
||||
if (!decoded || decoded.stage !== 'totp') return null
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
|
||||
function cookieMaxAge() {
|
||||
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
|
||||
@@ -71,11 +86,15 @@ function extractToken(req) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Returns the decoded user or null without rejecting the request.
|
||||
// Returns the decoded user or null without rejecting the request. Stage-tagged
|
||||
// tokens (e.g. the TOTP challenge) are explicitly NOT sessions, so an attacker
|
||||
// can't present a half-authenticated challenge token as a full login.
|
||||
function getUserFromRequest(req) {
|
||||
const token = extractToken(req)
|
||||
if (!token) return null
|
||||
return verifyToken(token)
|
||||
const decoded = verifyToken(token)
|
||||
if (!decoded || decoded.stage) return null
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Gate middleware for protected (admin) routes. Re-validates the token against
|
||||
@@ -110,6 +129,8 @@ module.exports = {
|
||||
COOKIE_NAME,
|
||||
signToken,
|
||||
verifyToken,
|
||||
signTotpChallenge,
|
||||
verifyTotpChallenge,
|
||||
setAuthCookie,
|
||||
clearAuthCookie,
|
||||
getUserFromRequest,
|
||||
|
||||
43
server/src/utils/totp.js
Normal file
43
server/src/utils/totp.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// ── TOTP (RFC 6238) helpers ────────────────────────────────────────────────
|
||||
//
|
||||
// Thin wrapper around speakeasy so the controllers stay small and the verify
|
||||
// logic is unit-testable in isolation. TOTP is opt-in per user: we generate a
|
||||
// base32 secret, show the user a QR (otpauth URL) to add to their authenticator,
|
||||
// confirm one code before enabling, and verify a code at login for users who
|
||||
// have it enabled.
|
||||
|
||||
const speakeasy = require('speakeasy')
|
||||
const QRCode = require('qrcode')
|
||||
|
||||
const ISSUER = process.env.TOTP_ISSUER || 'UOMysticmoon'
|
||||
|
||||
// Generate a new secret. Returns the base32 secret to persist plus the otpauth
|
||||
// URL to encode in a QR code.
|
||||
function generateSecret(username) {
|
||||
const secret = speakeasy.generateSecret({
|
||||
length: 20,
|
||||
name: `${ISSUER} (${username})`,
|
||||
issuer: ISSUER,
|
||||
})
|
||||
return { base32: secret.base32, otpauthUrl: secret.otpauth_url }
|
||||
}
|
||||
|
||||
// Render an otpauth URL to a PNG data URL for <img src>.
|
||||
async function qrDataUrl(otpauthUrl) {
|
||||
return QRCode.toDataURL(otpauthUrl)
|
||||
}
|
||||
|
||||
// Verify a user-supplied 6-digit code against a stored base32 secret. A window
|
||||
// of 1 tolerates minor clock skew (±30s). Returns false for missing inputs
|
||||
// rather than throwing.
|
||||
function verifyCode(base32Secret, token) {
|
||||
if (!base32Secret || !token) return false
|
||||
return speakeasy.totp.verify({
|
||||
secret: base32Secret,
|
||||
encoding: 'base32',
|
||||
token: String(token).trim(),
|
||||
window: 1,
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { generateSecret, qrDataUrl, verifyCode, ISSUER }
|
||||
102
server/src/utils/trustProxy.js
Normal file
102
server/src/utils/trustProxy.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// ── Trust proxy configuration ──────────────────────────────────────────────
|
||||
//
|
||||
// Real request path for this deployment:
|
||||
//
|
||||
// client → Pangolin → newt tunnel agent ("ptero", separate VM)
|
||||
// → this app (its own VM), over the LAN
|
||||
//
|
||||
// The hop that actually opens the TCP connection to this app is ptero, so from
|
||||
// Express's point of view ptero is THE trusted proxy and its LAN IP is the
|
||||
// right-most/most-recently-added entry to reconcile against. The real client IP
|
||||
// arrives in X-Forwarded-For. req.ip / req.secure must reflect the real client
|
||||
// because the rate limiter, exponential backoff, bot-scoring ban, and activity
|
||||
// log all key on req.ip — so this is a prerequisite for every other control.
|
||||
//
|
||||
// Recommended production value: pin TRUST_PROXY to ptero's LAN IP exactly. That
|
||||
// is stricter than a hop count: Express will only honour XFF on connections that
|
||||
// actually come from ptero, so nothing else on the LAN can inject a forwarded
|
||||
// header. See TRUST_PROXY in .env.example for how to set it.
|
||||
//
|
||||
// IMPORTANT ASSUMPTION: pinning ptero's IP assumes ptero holds a STATIC IP
|
||||
// (a DHCP reservation in Omada). If that reservation does not exist, a lease
|
||||
// change would silently move ptero to a new IP and every XFF would stop being
|
||||
// trusted — req.ip would collapse to ptero's (new) address for all clients,
|
||||
// breaking rate limiting/bans. Verify the reservation before relying on this,
|
||||
// and use DEBUG_TRUST_PROXY (below) to re-check the observed proxy IP without a
|
||||
// code redeploy if it ever needs to change.
|
||||
//
|
||||
// We deliberately DO NOT use a blanket `true`. `true` trusts every hop and takes
|
||||
// the left-most (client-supplied, spoofable) XFF entry, letting an attacker forge
|
||||
// their apparent IP to dodge rate limits / bans.
|
||||
//
|
||||
// TRUST_PROXY accepts:
|
||||
// - unset / '' -> 1 (single proxy hop fallback)
|
||||
// - an integer -> that many trusted hops (e.g. "2")
|
||||
// - "false" -> false (no proxy; direct connections only)
|
||||
// - "loopback" etc. -> the express preset string, passed through
|
||||
// - a CSV of IPs / CIDRs -> that list (e.g. ptero's LAN IP: "10.0.0.42")
|
||||
//
|
||||
// `true` is intentionally rejected (coerced to 1) with a warning, so it can't be
|
||||
// set by accident.
|
||||
|
||||
const log = require('./logger')('trustproxy')
|
||||
|
||||
function truthyEnv(v) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(v || '').trim().toLowerCase())
|
||||
}
|
||||
|
||||
const PRESETS = new Set(['loopback', 'linklocal', 'uniquelocal'])
|
||||
|
||||
function parseTrustProxy(raw = process.env.TRUST_PROXY) {
|
||||
const val = (raw == null ? '' : String(raw)).trim()
|
||||
|
||||
if (val === '') return 1 // default: one hop (Pangolin)
|
||||
if (val.toLowerCase() === 'false') return false
|
||||
if (val.toLowerCase() === 'true') {
|
||||
log.warn('TRUST_PROXY=true is unsafe (trusts spoofable client XFF); using 1 hop instead')
|
||||
return 1
|
||||
}
|
||||
|
||||
// Pure integer → hop count.
|
||||
if (/^\d+$/.test(val)) return Number(val)
|
||||
|
||||
// Single express preset keyword.
|
||||
if (PRESETS.has(val.toLowerCase())) return val.toLowerCase()
|
||||
|
||||
// Otherwise treat as a comma-separated list of trusted IPs / CIDRs (and/or
|
||||
// preset keywords), which Express accepts as an array.
|
||||
const list = val
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
return list.length === 1 ? list[0] : list
|
||||
}
|
||||
|
||||
// Apply the setting to an Express app and log what was chosen.
|
||||
function applyTrustProxy(app, raw = process.env.TRUST_PROXY) {
|
||||
const setting = parseTrustProxy(raw)
|
||||
app.set('trust proxy', setting)
|
||||
log.info('trust proxy configured', { setting: Array.isArray(setting) ? setting.join(',') : setting })
|
||||
return setting
|
||||
}
|
||||
|
||||
// Temporary diagnostic middleware, OFF by default. Set DEBUG_TRUST_PROXY=1 to
|
||||
// log, per request, the raw peer address and forwarded header alongside the IP
|
||||
// Express resolved — so the real proxy IP (ptero) can be re-verified in-place
|
||||
// without a code change if it ever moves. Mounted before the bot guard so it
|
||||
// still fires for scanner/junk requests (whose source IPs are what we want to
|
||||
// see). Turn it back off once verified; it is noisy.
|
||||
function trustProxyDebug(req, res, next) {
|
||||
if (truthyEnv(process.env.DEBUG_TRUST_PROXY)) {
|
||||
log.info('trust-proxy debug', {
|
||||
remoteAddress: req.socket && req.socket.remoteAddress,
|
||||
xForwardedFor: req.headers['x-forwarded-for'] || null,
|
||||
resolvedIp: req.ip,
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
})
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
module.exports = { parseTrustProxy, applyTrustProxy, trustProxyDebug }
|
||||
Reference in New Issue
Block a user