2 Commits

Author SHA1 Message Date
ea46b5d346 Merge pull request 'Admin login hardening: RBAC-safe controls, optional TOTP, bot-scoring + IP ban (closes #9)' (#19) from feature/admin-login-hardening into main
Reviewed-on: UOM/website#19
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 04:27:50 +00:00
d38c98ad9e 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>
2026-07-02 23:22:35 -05:00
30 changed files with 2038 additions and 54 deletions

View File

@@ -28,6 +28,21 @@ JWT_EXPIRES_IN=1d
COOKIE_SECURE=auto
COOKIE_NAME=uomm_token
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
# to ptero's LAN IP (e.g. 10.0.0.42) so XFF is only trusted from ptero. Requires
# a static DHCP reservation for ptero in Omada, else a lease change breaks it.
# Integer hop count or "false" also accepted; a blanket "true" is rejected
# (coerced to 1) to prevent X-Forwarded-For spoofing.
TRUST_PROXY=1
# Set to 1 to log raw peer address + X-Forwarded-For + resolved req.ip per
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
DEBUG_TRUST_PROXY=0
# Optional TOTP two-factor (opt-in per user).
TOTP_ISSUER=UOMysticmoon
TOTP_CHALLENGE_TTL=5m
# First admin bootstrap — created only if no users exist yet.
# Set, run once, then you can blank these out.
ADMIN_USERNAME=

View File

@@ -27,6 +27,7 @@ import HeroEditor from './routes/admin/views/HeroEditor.jsx'
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
export default function App() {
return (
@@ -71,6 +72,7 @@ export default function App() {
<Route path="settings" element={<SettingsAdmin />} />
<Route path="activity" element={<ActivityAdmin />} />
<Route path="users" element={<UsersAdmin />} />
<Route path="account" element={<AccountAdmin />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>

View File

@@ -41,7 +41,11 @@ function safeParse(text) {
export const api = {
// ----- auth -----
me: () => req('/auth/me'),
login: (username, password) => req('/auth/login', { method: 'POST', body: { username, password } }),
// `extra` carries the honeypot field (and any future login fields).
login: (username, password, extra = {}) =>
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
loginTotp: (challenge, code) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
logout: () => req('/auth/logout', { method: 'POST' }),
// ----- public -----
@@ -108,6 +112,12 @@ export const api = {
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// ----- account security (self-service 2FA) -----
getAccount: () => req('/admin/account'),
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
},
}

View File

@@ -22,8 +22,17 @@ export function AuthProvider({ children }) {
refresh()
}, [refresh])
const login = useCallback(async (username, password) => {
const data = await api.login(username, password)
// Step 1. Returns { user } on success, or { totpRequired, challenge } when the
// account has 2FA on (caller then calls loginTotp). `extra` carries honeypot.
const login = useCallback(async (username, password, extra) => {
const data = await api.login(username, password, extra)
if (data.user) setUser(data.user)
return data
}, [])
// Step 2 for TOTP users: exchange the challenge + code for a real session.
const loginTotp = useCallback(async (challenge, code) => {
const data = await api.loginTotp(challenge, code)
setUser(data.user)
return data.user
}, [])
@@ -37,7 +46,7 @@ export function AuthProvider({ children }) {
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
<AuthContext.Provider value={{ user, loading, login, loginTotp, logout, refresh }}>
{children}
</AuthContext.Provider>
)

View File

@@ -12,6 +12,7 @@ const NAV = [
{ to: '/admin/settings', label: 'Settings' },
{ to: '/admin/activity', label: 'Activity' },
{ to: '/admin/users', label: 'Users' },
{ to: '/admin/account', label: 'Account' },
]
const TITLES = {
@@ -22,6 +23,7 @@ const TITLES = {
'/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log',
'/admin/users': 'Users',
'/admin/account': 'Account Security',
}
const navBtnBase = {

View File

@@ -6,17 +6,36 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
const BG =
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
// skip) so real users never fill it but naive scripted bots do. Name must match
// the server's HONEYPOT_FIELD ('company').
const honeypotStyle = {
position: 'absolute',
left: '-9999px',
top: 'auto',
width: '1px',
height: '1px',
opacity: 0,
pointerEvents: 'none',
}
export default function AdminLogin() {
const { user, login } = useAuth()
const { user, login, loginTotp } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const dest = location.state?.from?.pathname || '/admin'
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [company, setCompany] = useState('') // honeypot — must stay empty
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Two-factor step state.
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('')
// Already signed in → go straight to the panel.
useEffect(() => {
if (user) navigate(dest, { replace: true })
@@ -27,7 +46,13 @@ export default function AdminLogin() {
setError('')
setBusy(true)
try {
await login(username, password)
const data = await login(username, password, { company })
if (data.totpRequired) {
setChallenge(data.challenge)
setStage('totp')
setBusy(false)
return
}
navigate(dest, { replace: true })
} catch (err) {
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
@@ -35,6 +60,24 @@ export default function AdminLogin() {
}
}
async function onSubmitTotp(e) {
e.preventDefault()
setError('')
setBusy(true)
try {
await loginTotp(challenge, code)
navigate(dest, { replace: true })
} catch (err) {
setError(
err.status === 401 && /expired/i.test(err.message)
? 'Your verification session expired. Please sign in again.'
: 'Invalid verification code.',
)
setBusy(false)
if (err.status === 401 && /expired/i.test(err.message)) setStage('creds')
}
}
return (
<main
style={{
@@ -63,7 +106,7 @@ export default function AdminLogin() {
</div>
<form
onSubmit={onSubmit}
onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}
style={{
border: '1px solid var(--line)',
borderRadius: 12,
@@ -73,6 +116,8 @@ export default function AdminLogin() {
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
}}
>
{stage === 'creds' ? (
<>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Username</span>
<input
@@ -95,6 +140,40 @@ export default function AdminLogin() {
/>
</label>
{/* Honeypot: hidden from humans, left empty; bots that fill it are rejected. */}
<div style={honeypotStyle} aria-hidden="true">
<label>
Company
<input
type="text"
name="company"
tabIndex={-1}
autoComplete="off"
value={company}
onChange={(e) => setCompany(e.target.value)}
/>
</label>
</div>
</>
) : (
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Authentication code</span>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
placeholder="6-digit code"
value={code}
onChange={(e) => setCode(e.target.value)}
className="input"
/>
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
Enter the code from your authenticator app.
</span>
</label>
)}
{error && (
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
{error}
@@ -107,7 +186,7 @@ export default function AdminLogin() {
className="btn btn-primary"
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
>
{busy ? 'Signing in…' : 'Sign in'}
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
</button>
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
Protected area not indexed. Sessions expire after 1 day.

View File

@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Self-service account security: enable / disable optional TOTP two-factor.
export default function AccountAdmin() {
const [account, setAccount] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Enrollment state.
const [setup, setSetup] = useState(null) // { qr, otpauthUrl }
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
async function load() {
try {
setAccount(await api.admin.getAccount())
} catch {
setError('Could not load your account.')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
async function beginSetup() {
setBusy(true)
setMsg('')
setError('')
try {
setSetup(await api.admin.totpSetup())
setCode('')
} catch (err) {
setError(err.message || 'Could not start setup.')
} finally {
setBusy(false)
}
}
async function confirmEnable() {
setBusy(true)
setMsg('')
setError('')
try {
await api.admin.totpEnable(code.trim())
setSetup(null)
setCode('')
setMsg('Two-factor authentication is now enabled.')
await load()
} catch (err) {
setError(err.message || 'Could not enable two-factor.')
} finally {
setBusy(false)
}
}
async function disable() {
setBusy(true)
setMsg('')
setError('')
try {
await api.admin.totpDisable(code.trim())
setCode('')
setMsg('Two-factor authentication has been disabled.')
await load()
} catch (err) {
setError(err.message || 'Could not disable two-factor.')
} finally {
setBusy(false)
}
}
const enabled = account?.totp_enabled
return (
<section style={{ maxWidth: 560 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Two-factor authentication
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Add a time-based one-time code (TOTP) from an authenticator app as a second step at login.
Optional, and only affects your own account.
</p>
<div
className="sans"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
border: '1px solid var(--line)',
fontSize: '0.82rem',
color: enabled ? '#7fd0a4' : 'var(--muted)',
marginBottom: 22,
}}
>
<span
style={{
width: 9,
height: 9,
borderRadius: '50%',
background: enabled ? '#7fd0a4' : 'var(--dim)',
}}
/>
{enabled ? 'Enabled' : 'Not enabled'}
</div>
{/* Enable flow */}
{!enabled && !setup && (
<div>
<button onClick={beginSetup} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Preparing…' : 'Set up two-factor'}
</button>
</div>
)}
{!enabled && setup && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
1. Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.
</p>
<img
src={setup.qr}
alt="TOTP QR code"
width={180}
height={180}
style={{ borderRadius: 8, background: '#fff', padding: 8, alignSelf: 'flex-start' }}
/>
<label style={{ display: 'block', maxWidth: 220 }}>
<span className="field-label">Verification code</span>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6-digit code"
value={code}
onChange={(e) => setCode(e.target.value)}
className="input"
/>
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={confirmEnable} disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
{busy ? 'Enabling…' : 'Confirm & enable'}
</button>
<button onClick={() => setSetup(null)} disabled={busy} className="pill">
Cancel
</button>
</div>
</div>
)}
{/* Disable flow */}
{enabled && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
Enter a current code from your authenticator to turn two-factor off.
</p>
<label style={{ display: 'block', maxWidth: 220 }}>
<span className="field-label">Verification code</span>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6-digit code"
value={code}
onChange={(e) => setCode(e.target.value)}
className="input"
/>
</label>
<div>
<button onClick={disable} disabled={busy || !code.trim()} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
{busy ? 'Disabling…' : 'Disable two-factor'}
</button>
</div>
</div>
)}
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</section>
)
}

View File

@@ -23,6 +23,30 @@ JWT_EXPIRES_IN=1d
COOKIE_SECURE=auto
COOKIE_NAME=uomm_token
# Reverse-proxy trust. Request path: client -> Pangolin -> newt agent "ptero"
# (separate VM) -> this app. ptero is the hop that connects to us, so pin
# TRUST_PROXY to ptero's LAN IP: Express then honours X-Forwarded-For ONLY on
# connections from ptero, and req.ip / req.secure reflect the real client (used
# by rate limiting, backoff, bot-ban, activity log).
# <ptero LAN IP> -> e.g. 10.0.0.42 (RECOMMENDED in prod; requires a static
# DHCP reservation for ptero in Omada — a lease change would
# silently break IP trust)
# an integer -> that many hops (fallback if you can't pin an IP)
# false -> no proxy (direct connections)
# NOTE: a blanket "true" is intentionally rejected (coerced to 1) — it would let
# clients spoof their IP via a forged X-Forwarded-For and dodge rate limits/bans.
TRUST_PROXY=1
# Set to 1 to log each request's raw peer address + X-Forwarded-For + resolved
# req.ip, so you can verify/refresh ptero's IP without redeploying. Noisy —
# leave off in normal operation.
DEBUG_TRUST_PROXY=0
# Optional TOTP two-factor (opt-in per user).
TOTP_ISSUER=UOMysticmoon
# How long the "password verified, awaiting code" step stays valid.
TOTP_CHALLENGE_TTL=5m
# Created on first boot if the users table is empty
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-admin-password

View File

@@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS users (
username VARCHAR(32) NOT NULL UNIQUE,
password_hash VARCHAR(72) NOT NULL,
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -122,6 +124,10 @@ CREATE TABLE IF NOT EXISTS activity_log (
-- these columns from the CREATE TABLE above; existing installs get them here.
-- (The category foreign key is only added on fresh installs; on upgraded databases
-- referential integrity for category_id is enforced in application code.)
-- Opt-in TOTP two-factor columns for databases created before login hardening.
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;

370
server/package-lock.json generated
View File

@@ -15,6 +15,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"express-slow-down": "^3.1.0",
"express-validator": "^7.2.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
@@ -22,7 +23,9 @@
"morgan": "^1.10.0",
"multer": "^2.0.1",
"nodemailer": "^9.0.1",
"sanitize-html": "^2.17.5"
"qrcode": "^1.5.4",
"sanitize-html": "^2.17.5",
"speakeasy": "^2.0.0"
},
"devDependencies": {
"nodemon": "^3.1.4"
@@ -56,6 +59,30 @@
"node": ">= 0.6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -92,6 +119,12 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/base32.js": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
"license": "MIT"
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
@@ -240,6 +273,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -265,6 +307,35 @@
"fsevents": "~2.3.2"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -361,6 +432,15 @@
"ms": "2.0.0"
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -398,6 +478,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -506,6 +592,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -645,6 +737,39 @@
"express": ">= 4.11"
}
},
"node_modules/express-slow-down": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/express-slow-down/-/express-slow-down-3.1.0.tgz",
"integrity": "sha512-0gZ1HHow8H83z1/+81DdWB60RSGHI0mJB0ZM1m5P6/BexORFcA8P1TgU0NKEwOiRmxyCIoktZYqmA+1UCc83+A==",
"license": "MIT",
"dependencies": {
"express-rate-limit": "8"
},
"engines": {
"node": ">= 16"
},
"peerDependencies": {
"express": "4 || 5 || ^5.0.0-beta.1"
}
},
"node_modules/express-slow-down/node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-validator": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
@@ -689,6 +814,19 @@
"node": ">= 0.8"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -731,6 +869,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -900,6 +1047,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -932,6 +1088,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -1022,6 +1187,18 @@
"dayjs": "^1.11.7"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1383,6 +1560,42 @@
"node": ">= 0.8"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
@@ -1398,6 +1611,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -1423,6 +1645,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -1471,6 +1702,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -1538,6 +1786,21 @@
"node": ">=8.10.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1636,6 +1899,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1736,6 +2005,18 @@
"node": ">=0.10.0"
}
},
"node_modules/speakeasy": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
"license": "MIT",
"dependencies": {
"base32.js": "0.0.1"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1762,6 +2043,32 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -1880,6 +2187,67 @@
"engines": {
"node": ">= 0.8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}

View File

@@ -7,7 +7,7 @@
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"seed": "node db/seed.js",
"test": "echo \"no tests yet\" && exit 0"
"test": "node --test"
},
"keywords": [
"express",
@@ -24,6 +24,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"express-slow-down": "^3.1.0",
"express-validator": "^7.2.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
@@ -31,7 +32,9 @@
"morgan": "^1.10.0",
"multer": "^2.0.1",
"nodemailer": "^9.0.1",
"sanitize-html": "^2.17.5"
"qrcode": "^1.5.4",
"sanitize-html": "^2.17.5",
"speakeasy": "^2.0.0"
},
"devDependencies": {
"nodemon": "^3.1.4"

View File

@@ -9,15 +9,28 @@ require('dotenv').config()
const apiRouter = require('./router/api.router')
const createLogger = require('./utils/logger')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore')
const httpLog = createLogger('http')
const errLog = createLogger('error')
const app = express()
// Behind Pangolin: trust the first proxy so req.secure (for the cookie flag),
// req.ip (activity log / rate limiting) reflect the X-Forwarded-* headers.
app.set('trust proxy', 1)
// Behind Pangolin: trust the forwarding proxy so req.secure (cookie flag) and
// req.ip (activity log, rate limiting, backoff, bot-ban) reflect the real client
// from X-Forwarded-*. Configurable via TRUST_PROXY; defaults to a single hop and
// never a blanket `true` (which would let clients spoof their IP). Must run
// before any middleware that reads req.ip.
applyTrustProxy(app)
// Optional trust-proxy diagnostics (off unless DEBUG_TRUST_PROXY is set). Before
// the bot guard so it logs scanner/junk source IPs too.
app.use(trustProxyDebug)
// Bot / scanner guard — mounted first (before helmet/routing) so banned IPs and
// obvious scanner probes are 404'd immediately without reaching real handlers.
app.use(botScore.guard)
// Security headers. CSP is left off here and will be tuned for the React SPA in
// the frontend phase; the rest of helmet's protections stay enabled.

View File

@@ -0,0 +1,228 @@
// ── Bot / scanner scoring and IP banning ──────────────────────────────────
//
// This app has no WordPress, Drupal, phpMyAdmin, .env exposure, etc. Any hit on
// those well-known scanner targets is therefore pure bot signal. Two separate
// jobs happen here, and it matters that they stay separate:
//
// 1. Junk-path 404: every hit to a known scanner path is 404'd immediately and
// UNCONDITIONALLY — independent of any IP score or ban state. Much of the
// scanning traffic here comes through Cloudflare edge ranges (104.23.x,
// 162.158.x, 172.68-71.x), i.e. a large rotating pool of source IPs, so we
// must never give a fresh IP a "free pass" on a junk path while its score
// warms up. The 404 is the primary, always-on defense.
//
// 2. Per-IP temp-ban: scoring accumulates per IP and, past a threshold, bans
// that IP from ALL routes for a while. This exists mainly to protect the
// real /admin login from credential stuffing once a scanner pivots from
// probing junk to attacking login — NOT to stop the scanning itself (fresh
// IPs are cheap for this actor, so an IP ban can't win that race). Because
// of that we bias toward a slightly LOWER threshold rather than a high one
// tuned to avoid false positives from a small/stable IP pool.
//
// State is a single-instance in-memory Map — fine for one Node process. Scores
// decay after a quiet period so a transient burst does not ban an IP forever.
//
// All time-based logic takes an optional `now` argument (defaulting to
// Date.now()) so the decay/ban windows are deterministic to test.
const log = require('../utils/logger')('botscore')
// Score at/above which an IP is banned from ALL routes. Deliberately on the low
// side (see job #2 above): fresh IPs are cheap for this actor, so we'd rather
// ban an attacking IP a little early than tune high to protect a stable pool.
const BAN_THRESHOLD = 80
// How long a ban lasts.
const BAN_MS = 60 * 60 * 1000 // 1 hour
// Quiet period after which a non-banned IP's accumulated score resets to 0.
const QUIET_MS = 30 * 60 * 1000 // 30 min
// Points added for a failed /admin login (wired in from the auth controller).
const LOGIN_FAIL_POINTS = 34
// Points for a tripped honeypot — an unambiguous bot, ban on sight.
const HONEYPOT_POINTS = BAN_THRESHOLD
// Weighted scanner paths, matched as a prefix against the lowercased request
// path, FIRST match wins — so more specific paths must precede their prefixes
// (e.g. /wp-admin/install.php before /wp-admin). Heavier weights = more damning.
//
// /wp-admin/install.php is by far the most-hit junk path in the real Pangolin
// access logs (from many rotating IPs), so it carries the single highest weight:
// a lone hit exceeds the ban threshold on its own — effectively a 1-hit ban —
// and outweighs every other individual path.
const PATH_WEIGHTS = [
['/wp-admin/install.php', 200], // top offender in prod logs — near 1-hit ban
['/.env', 100],
['/.git', 100],
['/.aws', 100],
['/wp-login.php', 100],
['/xmlrpc.php', 100],
['/wp-admin', 50],
['/administrator', 50],
['/phpmyadmin', 50],
['/mysql', 50],
['/wp-content', 40],
['/wp-includes', 40],
['/wp-json', 40],
['/user/login', 40], // Drupal
['/console', 40],
['/actuator', 40], // Spring Boot
['/vendor/phpunit', 100],
['/cgi-bin', 40],
]
// ip -> { score, lastSeen, bannedUntil }
const store = new Map()
// Return the scanner weight for a request path (0 if it is a legitimate path).
function scoreForPath(pathname) {
const p = String(pathname || '').toLowerCase()
for (const [prefix, weight] of PATH_WEIGHTS) {
if (p === prefix || p.startsWith(prefix)) return weight
}
return 0
}
function getEntry(ip) {
let e = store.get(ip)
if (!e) {
e = { score: 0, lastSeen: 0, bannedUntil: 0 }
store.set(ip, e)
}
return e
}
function isBanned(ip, now = Date.now()) {
const e = store.get(ip)
return Boolean(e && e.bannedUntil > now)
}
// Add points to an IP's score. Applies quiet-period decay first, then bans the
// IP if the new score crosses the threshold. Returns the updated entry.
function addScore(ip, points, now = Date.now(), reason = 'scan') {
const e = getEntry(ip)
// Decay: if the IP has been quiet longer than QUIET_MS (and is not currently
// banned), forget its accumulated score before adding the new hit.
if (e.bannedUntil <= now && e.lastSeen && now - e.lastSeen > QUIET_MS) {
e.score = 0
}
e.score += points
e.lastSeen = now
if (e.score >= BAN_THRESHOLD && e.bannedUntil <= now) {
e.bannedUntil = now + BAN_MS
log.warn('IP banned', { ip, score: e.score, reason, banMs: BAN_MS })
}
return e
}
// Points for a failed real login — called from the auth controller.
function recordLoginFailure(ip, now = Date.now()) {
return addScore(ip, LOGIN_FAIL_POINTS, now, 'login-fail')
}
// A tripped honeypot: instant ban-worthy score.
function recordHoneypot(ip, now = Date.now()) {
return addScore(ip, HONEYPOT_POINTS, now, 'honeypot')
}
// Early middleware: mounted before routing so banned IPs never reach a real
// handler. Everything here 404s (never 403) so we never confirm a path or a ban.
function guard(req, res, next) {
const ip = req.ip
const now = Date.now()
// (1) Known junk/scanner path → 404 FIRST, unconditionally. This is evaluated
// and returned before any ban check, so the 404 is fully independent of this
// IP's score/ban state: a scanner cycling through fresh Cloudflare IPs gets no
// free pass on a junk path. Scoring still runs (it accrues toward a /admin ban
// if the IP is reused), but the 404 does not depend on it.
const points = scoreForPath(req.path)
if (points > 0) {
const e = addScore(ip, points, now, 'scan')
log.warn('scanner path hit', { ip, path: req.path, points, score: e.score })
return notFound(res)
}
// (2) Non-junk path → block only if this IP is already banned (the
// credential-stuffing guard for the real /admin login), else let it through.
if (isBanned(ip, now)) {
log.debug('blocked banned IP', { ip, path: req.path })
return notFound(res)
}
return next()
}
// Uniform 404 — mirrors the SPA/API "Not found" shape without leaking anything.
function notFound(res) {
return res.status(404).json({ message: 'Not found' })
}
// ── Cleanup sweep ──────────────────────────────────────────────────────────
// Every unique IP that hits a scored path adds an entry and nothing else evicts
// it, so the store would grow unbounded. Periodically drop entries that are no
// longer meaningful: NOT banned and quiet longer than QUIET_MS (their score
// would already reset to 0 on next touch anyway). Banned entries, and entries
// still inside their quiet decay window, are left untouched. Returns the count
// removed. The eviction age reuses QUIET_MS; SWEEP_INTERVAL_MS is only cadence.
const SWEEP_INTERVAL_MS = 10 * 60 * 1000 // 10 min
function sweep(now = Date.now()) {
let removed = 0
for (const [ip, e] of store) {
if (e.bannedUntil <= now && now - e.lastSeen > QUIET_MS) {
store.delete(ip)
removed++
}
}
if (removed > 0) log.debug('store sweep', { removed, remaining: store.size })
return removed
}
let sweepTimer = null
function startSweeper() {
if (sweepTimer) return sweepTimer
sweepTimer = setInterval(() => sweep(), SWEEP_INTERVAL_MS)
// Never let the sweep timer alone keep the event loop alive (tests, shutdown).
if (sweepTimer.unref) sweepTimer.unref()
return sweepTimer
}
function stopSweeper() {
if (sweepTimer) {
clearInterval(sweepTimer)
sweepTimer = null
}
}
// Start sweeping on load — this is a single long-lived process.
startSweeper()
// Test/ops helpers.
function _reset() {
store.clear()
}
function _snapshot(ip) {
const e = store.get(ip)
return e ? { ...e } : null
}
module.exports = {
guard,
scoreForPath,
addScore,
isBanned,
recordLoginFailure,
recordHoneypot,
sweep,
startSweeper,
stopSweeper,
_reset,
_snapshot,
// Exported for tests / tuning.
BAN_THRESHOLD,
BAN_MS,
QUIET_MS,
LOGIN_FAIL_POINTS,
HONEYPOT_POINTS,
SWEEP_INTERVAL_MS,
}

View File

@@ -0,0 +1,94 @@
// ── Login brute-force protection ───────────────────────────────────────────
//
// Three independent layers guard the login endpoint:
//
// 1. slowDown — express-slow-down adds an increasing delay to each request
// once a few have been made in the window (before the hard cap
// bites), so a scripted burst is throttled but a human typing a
// wrong password twice barely notices.
// 2. loginLimiter (in rateLimit.js) — a hard 10-per-15-min cap per IP.
// 3. backoffGuard — a per-IP exponential backoff tracked in a SEPARATE store
// from the rate limiter, keyed on *failed* attempts. Because it
// is separate it persists across the rate limiter's window
// reset: an IP that keeps failing keeps getting locked out for
// longer, independent of the sliding 15-min window.
//
// The backoff store is in-memory (single instance). Times take an optional `now`
// so the escalation/decay is deterministic to test.
const slowDown = require('express-slow-down')
const log = require('../utils/logger')('loginprotect')
// Progressive delay: no delay for the first few attempts, then +0.5s each,
// capped so a request never hangs too long.
const slowLogin = slowDown({
windowMs: 15 * 60 * 1000,
delayAfter: 3,
delayMs: (used) => (used - 3) * 500,
maxDelayMs: 20 * 1000,
})
// Exponential backoff on consecutive failures.
const BASE_MS = 1000 // first failure locks ~1s
const MAX_MS = 15 * 60 * 1000 // cap a single lock at 15 min
const RESET_MS = 30 * 60 * 1000 // forget the streak after this much quiet
// ip -> { count, blockedUntil, lastFailure }
const store = new Map()
// Record a failed login and lengthen this IP's lockout. Returns ms locked.
function recordFailure(ip, now = Date.now()) {
let e = store.get(ip)
if (!e || now - e.lastFailure > RESET_MS) {
e = { count: 0, blockedUntil: 0, lastFailure: 0 }
store.set(ip, e)
}
e.count += 1
e.lastFailure = now
const delay = Math.min(BASE_MS * 2 ** (e.count - 1), MAX_MS)
e.blockedUntil = now + delay
log.warn('login failure recorded', { ip, count: e.count, lockMs: delay })
return delay
}
// Clear an IP's failure streak after a successful login.
function recordSuccess(ip) {
store.delete(ip)
}
// How long (ms) this IP is still locked out for, 0 if not locked.
function retryAfterMs(ip, now = Date.now()) {
const e = store.get(ip)
if (!e || e.blockedUntil <= now) return 0
return e.blockedUntil - now
}
// Middleware: reject while the IP is in its backoff window. Generic message —
// never reveals whether the username or the password was the problem.
function backoffGuard(req, res, next) {
const wait = retryAfterMs(req.ip)
if (wait > 0) {
res.set('Retry-After', String(Math.ceil(wait / 1000)))
log.warn('login blocked by backoff', { ip: req.ip, retryAfterMs: wait })
return res.status(429).json({ message: 'Too many login attempts. Please try again later.' })
}
return next()
}
// Test helpers.
function _reset() {
store.clear()
}
module.exports = {
slowLogin,
backoffGuard,
recordFailure,
recordSuccess,
retryAfterMs,
_reset,
BASE_MS,
MAX_MS,
RESET_MS,
}

View File

@@ -1,6 +1,6 @@
const { query } = require('../../utils/db')
const PUBLIC_COLS = 'id, username, role, created_at, last_login_at'
const PUBLIC_COLS = 'id, username, role, totp_enabled, created_at, last_login_at'
async function insertUser({ username, passwordHash, role = 'admin' }) {
const res = await query(
@@ -54,6 +54,20 @@ async function touchLastLogin(id) {
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id])
}
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
// so a secret is never trusted until the user has confirmed one code.
async function setTotpSecret(id, secret) {
return query('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?', [secret, id])
}
async function enableTotp(id) {
return query('UPDATE users SET totp_enabled = 1 WHERE id = ?', [id])
}
async function disableTotp(id) {
return query('UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?', [id])
}
module.exports = {
insertUser,
findByUsername,
@@ -64,4 +78,7 @@ module.exports = {
countUsers,
countAdmins,
touchLastLogin,
setTotpSecret,
enableTotp,
disableTotp,
}

View File

@@ -3,10 +3,10 @@ const usersDb = require('./users.db')
const SALT_ROUNDS = 10
// Strip the password hash before sending a user anywhere.
// Strip secrets (password hash, TOTP secret) before sending a user anywhere.
function sanitize(user) {
if (!user) return null
const { password_hash, ...safe } = user
const { password_hash, totp_secret, ...safe } = user
return safe
}
@@ -25,6 +25,24 @@ async function getById(id) {
return sanitize(await usersDb.findById(id))
}
// Raw row incl. totp_secret — server-side only (TOTP setup/verify). Never sent
// to a client; sanitize() strips the secret from anything user-facing.
async function getRawById(id) {
return usersDb.findById(id)
}
async function setTotpSecret(id, secret) {
return usersDb.setTotpSecret(id, secret)
}
async function enableTotp(id) {
return usersDb.enableTotp(id)
}
async function disableTotp(id) {
return usersDb.disableTotp(id)
}
async function validatePassword(user, password) {
if (!user || !user.password_hash) return false
return bcrypt.compare(password, user.password_hash)
@@ -63,6 +81,7 @@ module.exports = {
createUser,
getRawByUsername,
getById,
getRawById,
validatePassword,
list,
update,
@@ -70,4 +89,7 @@ module.exports = {
count,
countAdmins,
recordLogin,
setTotpSecret,
enableTotp,
disableTotp,
}

View File

@@ -0,0 +1,84 @@
// Self-service account security for the logged-in user (any role). Mounted under
// the admin router (so isLoggedIn has already run and req.user is the fresh DB
// row), but NOT behind the admin-only gate — editors manage their own 2FA too.
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const totp = require('../../../utils/totp')
const log = require('../../../utils/logger')('account')
// Current user's security status (does not expose the secret).
async function getAccount(req, res) {
return res.json({
id: req.user.id,
username: req.user.username,
role: req.user.role,
totp_enabled: Boolean(req.user.totp_enabled),
})
}
// Step 1: generate a fresh secret (stored but not yet enabled) and return the
// otpauth URL + a QR data URL for the user to scan. Overwrites any pending,
// not-yet-confirmed secret. Refuses if TOTP is already enabled.
async function totpSetup(req, res) {
try {
if (req.user.totp_enabled) {
return res.status(409).json({ message: 'Two-factor is already enabled. Disable it first to re-enroll.' })
}
const { base32, otpauthUrl } = totp.generateSecret(req.user.username)
await users.setTotpSecret(req.user.id, base32)
const qr = await totp.qrDataUrl(otpauthUrl)
log.info('totp setup started', { id: req.user.id, username: req.user.username })
return res.json({ otpauthUrl, qr })
} catch (err) {
log.error('totpSetup', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Step 2: confirm one code against the pending secret, then flip totp_enabled on.
async function totpEnable(req, res) {
try {
const user = await users.getRawById(req.user.id)
if (!user || !user.totp_secret) {
return res.status(400).json({ message: 'Start setup before enabling two-factor.' })
}
if (user.totp_enabled) {
return res.status(409).json({ message: 'Two-factor is already enabled.' })
}
if (!totp.verifyCode(user.totp_secret, req.body.code)) {
return res.status(400).json({ message: 'That code is not valid. Try again.' })
}
await users.enableTotp(user.id)
await activity.log({ req, action: 'account.totp.enable' })
log.info('totp enabled', { id: user.id, username: user.username })
return res.json({ totp_enabled: true })
} catch (err) {
log.error('totpEnable', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Turn TOTP off. Require a current code to prove the requester still controls the
// authenticator (so a walk-up on an open session can't quietly remove 2FA).
async function totpDisable(req, res) {
try {
const user = await users.getRawById(req.user.id)
if (!user || !user.totp_enabled) {
return res.status(400).json({ message: 'Two-factor is not enabled.' })
}
if (!totp.verifyCode(user.totp_secret, req.body.code)) {
return res.status(400).json({ message: 'That code is not valid. Try again.' })
}
await users.disableTotp(user.id)
await activity.log({ req, action: 'account.totp.disable' })
log.info('totp disabled', { id: user.id, username: user.username })
return res.json({ totp_enabled: false })
} catch (err) {
log.error('totpDisable', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getAccount, totpSetup, totpEnable, totpDisable }

View File

@@ -6,6 +6,7 @@ const multer = require('multer')
const { body, param } = require('express-validator')
const ctrl = require('./admin.controller')
const account = require('./account.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
@@ -19,6 +20,23 @@ adminRouter.use(noindex, isLoggedIn)
// management, site mode, and settings are restricted to the admin role.
const adminOnly = requireRole('admin')
// ── Account security (self-service, any logged-in role) ───────────────
// Not behind adminOnly: an editor manages their own 2FA too.
adminRouter.get('/account', account.getAccount)
adminRouter.post('/account/totp/setup', account.totpSetup)
adminRouter.post(
'/account/totp/enable',
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpEnable,
)
adminRouter.post(
'/account/totp/disable',
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpDisable,
)
// ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR =
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')

View File

@@ -1,35 +1,105 @@
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const { signToken, setAuthCookie, clearAuthCookie } = require('../../../utils/auth')
const {
signToken,
setAuthCookie,
clearAuthCookie,
signTotpChallenge,
verifyTotpChallenge,
} = require('../../../utils/auth')
const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
const log = require('../../../utils/logger')('auth')
async function login(req, res) {
const { username, password } = req.body
try {
const user = await users.getRawByUsername(username)
const ok = user && (await users.validatePassword(user, password))
if (!ok) {
log.warn('login failed', { username, ip: req.ip })
return res.status(401).json({ message: 'Incorrect username or password.' })
}
// Honeypot input name — must match the hidden field rendered on the login form.
// Chosen to look like a real field so naive bots fill it; real users never see it.
const HONEYPOT_FIELD = 'company'
// One generic failure response for every "you don't get in" case (wrong user,
// wrong password, tripped honeypot). Never reveals which was wrong.
const GENERIC_FAIL = { message: 'Incorrect username or password.' }
// True when this user must complete a second factor before getting a session.
function needsTotp(user) {
return Boolean(user && user.totp_enabled)
}
// Issue the real session: sign the JWT, set the cookie, clear the IP's failure
// backoff, and record the login.
async function issueSession(req, res, user) {
loginProtection.recordSuccess(req.ip)
await users.recordLogin(user.id)
const token = signToken(user)
setAuthCookie(req, res, token)
await activity.log({ req, userId: user.id, action: 'auth.login' })
log.info('login success', { username: user.username, id: user.id, ip: req.ip })
return res.json({ user: { id: user.id, username: user.username, role: user.role } })
}
return res.json({
user: { id: user.id, username: user.username, role: user.role },
})
async function login(req, res) {
const { username, password } = req.body
// Honeypot: a populated hidden field means a bot. Fail generically, but score
// it hard — this is an unambiguous signal, unlike a mistyped password.
if (req.body[HONEYPOT_FIELD]) {
botScore.recordHoneypot(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('honeypot login hit', { ip: req.ip, username })
return res.status(401).json(GENERIC_FAIL)
}
try {
const user = await users.getRawByUsername(username)
const ok = user && (await users.validatePassword(user, password))
if (!ok) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('login failed', { username, ip: req.ip })
return res.status(401).json(GENERIC_FAIL)
}
// Password is correct. If this user has TOTP on, do NOT issue a session yet —
// hand back a short-lived, signed "password verified" challenge and require
// the code. If TOTP is off, log them straight in.
if (needsTotp(user)) {
const challenge = signTotpChallenge(user)
log.info('password ok, awaiting TOTP', { username: user.username, id: user.id, ip: req.ip })
return res.json({ totpRequired: true, challenge })
}
return issueSession(req, res, user)
} catch (err) {
log.error('login error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function logout(req, res) {
// Second step for TOTP users: verify the challenge token + code, then issue the
// session. A wrong code counts as a failed attempt (backoff + bot score).
async function loginTotp(req, res) {
const { challenge, code } = req.body
const decoded = verifyTotpChallenge(challenge)
if (!decoded) {
return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' })
}
try {
const user = await users.getRawById(decoded.id)
if (!user || !user.totp_enabled || !totp.verifyCode(user.totp_secret, code)) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('TOTP verify failed', { id: decoded.id, ip: req.ip })
return res.status(401).json({ message: 'Invalid verification code.' })
}
return issueSession(req, res, user)
} catch (err) {
log.error('loginTotp error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
function logout(req, res) {
clearAuthCookie(req, res)
return res.json({ message: 'Logged out.' })
}
@@ -44,4 +114,4 @@ async function me(req, res) {
}
}
module.exports = { login, logout, me }
module.exports = { login, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD }

View File

@@ -1,21 +1,42 @@
const express = require('express')
const { body } = require('express-validator')
const { login, logout, me } = require('./auth.controller')
const { login, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
const { isLoggedIn } = require('../../../utils/auth')
const { loginLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const validate = require('../../../middleware/validate')
const authRouter = express.Router()
// Login protection order (cheapest rejection first):
// backoffGuard → per-IP exponential lockout on repeated failures
// slowLogin → progressive per-request delay within the window
// loginLimiter → hard 10-per-15-min cap
const loginGuards = [backoffGuard, slowLogin, loginLimiter]
authRouter.post(
'/login',
loginLimiter,
...loginGuards,
body('username').isString().trim().notEmpty(),
body('password').isString().notEmpty(),
// Honeypot must be absent/empty for humans; bots that fill it are caught in
// the controller. Accept-but-ignore here so a filled value still reaches it.
body(HONEYPOT_FIELD).optional(),
validate,
login,
)
// Second factor: same throttling, since it's a code-guessing surface too.
authRouter.post(
'/login/totp',
...loginGuards,
body('challenge').isString().notEmpty(),
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
loginTotp,
)
authRouter.post('/logout', logout)
authRouter.get('/me', isLoggedIn, me)

View File

@@ -2,6 +2,7 @@ require('dotenv').config()
const http = require('http')
const app = require('./app')
const botScore = require('./middleware/botScore')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
@@ -47,6 +48,7 @@ function setupShutdown(server) {
if (closing) return
closing = true
log.warn(`${signal} received — shutting down gracefully`)
botScore.stopSweeper() // stop the bot-store cleanup interval
server.close(() => log.info('http server closed'))
try {
await close()

View File

@@ -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
View 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 }

View 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 }

21
server/test/_helper.js Normal file
View File

@@ -0,0 +1,21 @@
// Test helper: start a throwaway Express app on an ephemeral port and return its
// base URL + a close(). Uses the built-in fetch (Node 18+) so tests need no
// extra HTTP dependency. Tests here exercise middleware in isolation and do NOT
// touch the database.
const express = require('express')
async function startApp(configure) {
const app = express()
app.use(express.json())
configure(app)
const server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
const { port } = server.address()
return {
url: `http://127.0.0.1:${port}`,
close: () => new Promise((resolve) => server.close(resolve)),
}
}
module.exports = { startApp }

View File

@@ -0,0 +1,192 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const botScore = require('../src/middleware/botScore')
const { startApp } = require('./_helper')
beforeEach(() => botScore._reset())
test('scoreForPath: scanner paths score, legitimate app paths do not', () => {
assert.ok(botScore.scoreForPath('/wp-admin') > 0)
assert.ok(botScore.scoreForPath('/wp-login.php') > 0)
assert.ok(botScore.scoreForPath('/.env') > 0)
assert.ok(botScore.scoreForPath('/xmlrpc.php') > 0)
// The real admin path and API are NOT scanner signal.
assert.equal(botScore.scoreForPath('/admin'), 0)
assert.equal(botScore.scoreForPath('/admin/login'), 0)
assert.equal(botScore.scoreForPath('/api/v1/auth/login'), 0)
assert.equal(botScore.scoreForPath('/'), 0)
})
test('/wp-admin/install.php is the single highest-weighted path (near 1-hit ban)', () => {
const install = botScore.scoreForPath('/wp-admin/install.php')
// Higher than the generic /wp-admin prefix (more specific entry wins first)...
assert.ok(install > botScore.scoreForPath('/wp-admin'))
// ...and higher than every other scanner path.
for (const p of ['/.env', '/.git', '/wp-login.php', '/xmlrpc.php', '/phpmyadmin', '/wp-json']) {
assert.ok(install > botScore.scoreForPath(p), `install.php should outweigh ${p}`)
}
// A single hit alone meets/exceeds the ban threshold → effectively a 1-hit ban.
assert.ok(install >= botScore.BAN_THRESHOLD)
})
test('install.php 404s on a first-time-seen IP (before any ban would trigger)', async () => {
const freshIp = '203.0.113.80'
// Precondition: this IP has never been seen, so it is NOT banned yet.
assert.equal(botScore.isBanned(freshIp), false)
const app = await startApp((a) => {
a.set('trust proxy', 1)
a.use(botScore.guard)
a.get('/', (req, res) => res.json({ ok: true }))
})
try {
// The very first hit to the junk path must 404 — the 404 comes from the
// junk-path rule, independent of the (currently empty) ban state.
const res = await fetch(`${app.url}/wp-admin/install.php`, {
headers: { 'X-Forwarded-For': freshIp },
})
assert.equal(res.status, 404)
} finally {
await app.close()
}
})
test('a low-weight junk path 404s on first hit WITHOUT a ban (404 is ban-independent)', async () => {
const freshIp = '203.0.113.81'
const app = await startApp((a) => {
a.set('trust proxy', 1)
a.use(botScore.guard)
a.get('/', (req, res) => res.json({ ok: true }))
})
try {
// /wp-content scores 40 (< threshold 80): the hit 404s but does NOT ban,
// proving the immediate 404 does not depend on the IP having crossed the
// ban threshold.
const res = await fetch(`${app.url}/wp-content/uploads/x.php`, {
headers: { 'X-Forwarded-For': freshIp },
})
assert.equal(res.status, 404)
assert.equal(botScore.isBanned(freshIp), false)
} finally {
await app.close()
}
})
test('addScore bans once the threshold is crossed', () => {
const ip = '198.51.100.1'
const now = 1_000_000
assert.equal(botScore.isBanned(ip, now), false)
botScore.addScore(ip, botScore.BAN_THRESHOLD - 1, now)
assert.equal(botScore.isBanned(ip, now), false) // just under
botScore.addScore(ip, 1, now)
assert.equal(botScore.isBanned(ip, now), true) // at threshold
})
test('a single .env probe (weight 100) is an instant ban', () => {
const ip = '198.51.100.9'
const now = 5_000
botScore.addScore(ip, botScore.scoreForPath('/.env'), now)
assert.equal(botScore.isBanned(ip, now), true)
})
test('login failures + one scan hit ban faster than either alone', () => {
const ip = '198.51.100.2'
const now = 2_000_000
// Two failed logins alone: below threshold, not banned.
botScore.recordLoginFailure(ip, now)
botScore.recordLoginFailure(ip, now)
assert.equal(botScore.isBanned(ip, now), false)
// Add one medium scanner hit (wp-admin, 50) → crosses threshold.
botScore.addScore(ip, botScore.scoreForPath('/wp-admin'), now)
assert.equal(botScore.isBanned(ip, now), true)
})
test('score decays to zero after a quiet period', () => {
const ip = '198.51.100.3'
const t0 = 10_000
botScore.addScore(ip, 50, t0) // below threshold
// Long quiet gap, then another 50 — should NOT ban because the first decayed.
const later = t0 + botScore.QUIET_MS + 1
botScore.addScore(ip, 50, later)
assert.equal(botScore.isBanned(ip, later), false)
assert.equal(botScore._snapshot(ip).score, 50)
})
test('ban persists for the ban window and lifts after it', () => {
const ip = '198.51.100.4'
const now = 3_000_000
botScore.addScore(ip, botScore.BAN_THRESHOLD, now)
assert.equal(botScore.isBanned(ip, now + botScore.BAN_MS - 1), true)
assert.equal(botScore.isBanned(ip, now + botScore.BAN_MS + 1), false)
})
test('sweep removes stale unbanned entries but keeps banned and recent ones', () => {
const t0 = 100_000_000
// (a) stale + unbanned: scored below threshold, then goes quiet past QUIET_MS.
botScore.addScore('10.0.0.1', 50, t0)
// (b) banned + quiet: banned now, and its lastSeen is old at sweep time — must
// survive because the ban is still active.
botScore.addScore('10.0.0.2', botScore.BAN_THRESHOLD, t0)
// (c) recently active: scored just before the sweep, still inside QUIET_MS.
const sweepAt = t0 + botScore.QUIET_MS + 1
botScore.addScore('10.0.0.3', 50, sweepAt)
const removed = botScore.sweep(sweepAt)
assert.equal(removed, 1) // only the stale unbanned entry
assert.equal(botScore._snapshot('10.0.0.1'), null) // (a) evicted
assert.notEqual(botScore._snapshot('10.0.0.2'), null) // (b) banned → survives
assert.equal(botScore.isBanned('10.0.0.2', sweepAt), true)
assert.notEqual(botScore._snapshot('10.0.0.3'), null) // (c) recent → survives
})
test('sweep keeps an unbanned entry that is exactly at the quiet boundary', () => {
const t0 = 200_000_000
botScore.addScore('10.0.1.1', 40, t0)
// now - lastSeen === QUIET_MS (not strictly greater) → not yet evictable.
const removed = botScore.sweep(t0 + botScore.QUIET_MS)
assert.equal(removed, 0)
assert.notEqual(botScore._snapshot('10.0.1.1'), null)
})
test('guard: scanner junk path returns 404, legit path passes through', async () => {
const app = await startApp((a) => {
a.set('trust proxy', 1)
a.use(botScore.guard)
a.get('/', (req, res) => res.json({ ok: true }))
a.get('/admin', (req, res) => res.json({ ok: 'admin' }))
})
try {
const scan = await fetch(`${app.url}/wp-login.php`, { headers: { 'X-Forwarded-For': '203.0.113.20' } })
assert.equal(scan.status, 404)
const ok = await fetch(`${app.url}/admin`, { headers: { 'X-Forwarded-For': '203.0.113.21' } })
assert.equal(ok.status, 200)
} finally {
await app.close()
}
})
test('guard: once banned, an IP gets 404 on ALL routes', async () => {
const bannedIp = '203.0.113.30'
const app = await startApp((a) => {
a.set('trust proxy', 1)
a.use(botScore.guard)
a.get('/', (req, res) => res.json({ ok: true }))
})
try {
// One .env probe → instant ban for this IP.
const probe = await fetch(`${app.url}/.env`, { headers: { 'X-Forwarded-For': bannedIp } })
assert.equal(probe.status, 404)
// Now a normal path from the same IP is also 404.
const blocked = await fetch(`${app.url}/`, { headers: { 'X-Forwarded-For': bannedIp } })
assert.equal(blocked.status, 404)
// A different IP still gets through.
const other = await fetch(`${app.url}/`, { headers: { 'X-Forwarded-For': '203.0.113.31' } })
assert.equal(other.status, 200)
} finally {
await app.close()
}
})

View File

@@ -0,0 +1,76 @@
// 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)
})

View File

@@ -0,0 +1,96 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const lp = require('../src/middleware/loginProtection')
const { loginLimiter } = require('../src/middleware/rateLimit')
const { startApp } = require('./_helper')
beforeEach(() => lp._reset())
test('recordFailure escalates the lockout exponentially', () => {
const ip = '198.51.100.50'
const now = 1_000_000
const first = lp.recordFailure(ip, now)
const second = lp.recordFailure(ip, now)
const third = lp.recordFailure(ip, now)
assert.equal(first, lp.BASE_MS) // 2^0
assert.equal(second, lp.BASE_MS * 2) // 2^1
assert.equal(third, lp.BASE_MS * 4) // 2^2
})
test('lockout is capped at MAX_MS', () => {
const ip = '198.51.100.51'
const now = 1_000_000
let last = 0
for (let i = 0; i < 40; i++) last = lp.recordFailure(ip, now)
assert.equal(last, lp.MAX_MS)
})
test('retryAfterMs reflects the active lockout and clears after it elapses', () => {
const ip = '198.51.100.52'
const now = 2_000_000
lp.recordFailure(ip, now) // locks BASE_MS
assert.ok(lp.retryAfterMs(ip, now) > 0)
assert.equal(lp.retryAfterMs(ip, now + lp.BASE_MS + 1), 0)
})
test('recordSuccess clears the failure streak', () => {
const ip = '198.51.100.53'
const now = 2_000_000
lp.recordFailure(ip, now)
lp.recordSuccess(ip)
assert.equal(lp.retryAfterMs(ip, now), 0)
})
test('streak resets after a long quiet period (does not grow forever)', () => {
const ip = '198.51.100.54'
const t0 = 3_000_000
lp.recordFailure(ip, t0)
lp.recordFailure(ip, t0)
// Come back after RESET_MS+ of quiet: next failure starts the streak over.
const later = t0 + lp.RESET_MS + 1
const delay = lp.recordFailure(ip, later)
assert.equal(delay, lp.BASE_MS) // back to 2^0
})
test('backoffGuard returns a generic 429 while locked out', async () => {
// Pre-lock this IP, then confirm the guard blocks it with a generic message.
const app = await startApp((a) => {
a.set('trust proxy', 1)
a.post('/login', lp.backoffGuard, (req, res) => res.json({ ok: true }))
})
try {
lp.recordFailure('203.0.113.40') // lock the test client IP
const res = await fetch(`${app.url}/login`, {
method: 'POST',
headers: { 'X-Forwarded-For': '203.0.113.40' },
})
assert.equal(res.status, 429)
const body = await res.json()
assert.match(body.message, /too many login attempts/i)
// Message must not reveal whether username or password was the problem.
assert.doesNotMatch(body.message, /password|username/i)
assert.ok(res.headers.get('retry-after'))
} finally {
await app.close()
}
})
test('hard rate limiter caps attempts per IP (429 after the cap)', async () => {
const app = await startApp((a) => {
a.set('trust proxy', 1)
a.post('/login', loginLimiter, (req, res) => res.json({ ok: true }))
})
try {
const headers = { 'X-Forwarded-For': '203.0.113.41' }
let sawLimit = false
// Cap is 10/15min; the 11th should be blocked.
for (let i = 0; i < 12; i++) {
const res = await fetch(`${app.url}/login`, { method: 'POST', headers })
if (res.status === 429) sawLimit = true
}
assert.ok(sawLimit, 'expected the hard limiter to return 429 after the cap')
} finally {
await app.close()
}
})

65
server/test/totp.test.js Normal file
View File

@@ -0,0 +1,65 @@
// Set before requiring auth.js (reads JWT_SECRET at load) and db.js (builds the
// pool at load). Pointing the DB at a closed port stops the pool from eagerly
// opening idle connections that would keep this test process alive — none of
// these tests touch the database.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const speakeasy = require('speakeasy')
const totp = require('../src/utils/totp')
const { needsTotp } = require('../src/router/v1/auth/auth.controller')
const { signTotpChallenge, verifyTotpChallenge, getUserFromRequest } = require('../src/utils/auth')
const db = require('../src/utils/db')
after(() => db.close())
test('needsTotp: disabled user does not require a second factor', () => {
assert.equal(needsTotp({ id: 1, totp_enabled: 0 }), false)
assert.equal(needsTotp({ id: 1 }), false)
})
test('needsTotp: enabled user requires a second factor', () => {
assert.equal(needsTotp({ id: 1, totp_enabled: 1 }), true)
})
test('verifyCode accepts a current code and rejects a wrong/absent one', () => {
const { base32 } = totp.generateSecret('alice')
const good = speakeasy.totp({ secret: base32, encoding: 'base32' })
assert.equal(totp.verifyCode(base32, good), true)
assert.equal(totp.verifyCode(base32, '000000'), false)
assert.equal(totp.verifyCode(base32, ''), false)
assert.equal(totp.verifyCode(null, good), false)
})
test('generateSecret yields a base32 secret and an otpauth URL', () => {
const s = totp.generateSecret('bob')
assert.ok(s.base32 && s.base32.length >= 16)
assert.match(s.otpauthUrl, /^otpauth:\/\/totp\//)
})
test('qrDataUrl renders the otpauth URL to a PNG data URL', async () => {
const s = totp.generateSecret('carol')
const dataUrl = await totp.qrDataUrl(s.otpauthUrl)
assert.match(dataUrl, /^data:image\/png;base64,/)
})
// The password-verified challenge must never work as a real session token.
test('TOTP challenge token is not accepted as a session', () => {
const token = signTotpChallenge({ id: 42 })
// Valid as a challenge...
const challenge = verifyTotpChallenge(token)
assert.equal(challenge.id, 42)
// ...but rejected as a session (stage-tagged) when presented as a cookie/bearer.
const req = { cookies: {}, headers: { authorization: `Bearer ${token}` } }
assert.equal(getUserFromRequest(req), null)
})
test('a normal session token is not accepted as a TOTP challenge', () => {
const { signToken } = require('../src/utils/auth')
const session = signToken({ id: 7, username: 'x', role: 'admin' })
assert.equal(verifyTotpChallenge(session), null)
})

View File

@@ -0,0 +1,89 @@
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()
}
})