diff --git a/.env.example b/.env.example index f72080e..fa3617f 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/client/src/App.jsx b/client/src/App.jsx index 1829be4..ce2e82c 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 474e78c..bf49955 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -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 } }), }, } diff --git a/client/src/contexts/AuthContext.jsx b/client/src/contexts/AuthContext.jsx index 5c41024..5a78b14 100644 --- a/client/src/contexts/AuthContext.jsx +++ b/client/src/contexts/AuthContext.jsx @@ -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 ( - + {children} ) diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 4fd70c9..c6d69b7 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -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 = { diff --git a/client/src/routes/admin/AdminLogin.jsx b/client/src/routes/admin/AdminLogin.jsx index 47a738d..8c35487 100644 --- a/client/src/routes/admin/AdminLogin.jsx +++ b/client/src/routes/admin/AdminLogin.jsx @@ -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 ( - - Username - setUsername(e.target.value)} - className="input" - /> - - - Password - setPassword(e.target.value)} - className="input" - /> - + {stage === 'creds' ? ( + <> + + Username + setUsername(e.target.value)} + className="input" + /> + + + Password + setPassword(e.target.value)} + className="input" + /> + + + {/* Honeypot: hidden from humans, left empty; bots that fill it are rejected. */} + + + Company + setCompany(e.target.value)} + /> + + + > + ) : ( + + Authentication code + setCode(e.target.value)} + className="input" + /> + + Enter the code from your authenticator app. + + + )} {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'} Protected area — not indexed. Sessions expire after 1 day. diff --git a/client/src/routes/admin/views/AccountAdmin.jsx b/client/src/routes/admin/views/AccountAdmin.jsx new file mode 100644 index 0000000..ab0fbca --- /dev/null +++ b/client/src/routes/admin/views/AccountAdmin.jsx @@ -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 + if (error) return + + 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 ( + + + Two-factor authentication + + + 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. + + + + + {enabled ? 'Enabled' : 'Not enabled'} + + + {/* Enable flow */} + {!enabled && !setup && ( + + + {busy ? 'Preparing…' : 'Set up two-factor'} + + + )} + + {!enabled && setup && ( + + + 1. Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm. + + + + Verification code + setCode(e.target.value)} + className="input" + /> + + + + {busy ? 'Enabling…' : 'Confirm & enable'} + + setSetup(null)} disabled={busy} className="pill"> + Cancel + + + + )} + + {/* Disable flow */} + {enabled && ( + + + Enter a current code from your authenticator to turn two-factor off. + + + Verification code + setCode(e.target.value)} + className="input" + /> + + + + {busy ? 'Disabling…' : 'Disable two-factor'} + + + + )} + + {msg && {msg}} + {error && {error}} + + ) +} diff --git a/server/.env.example b/server/.env.example index b2dd4e0..1fff7f0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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). +# -> 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 diff --git a/server/db/schema.sql b/server/db/schema.sql index 5caaf31..9c74697 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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; diff --git a/server/package-lock.json b/server/package-lock.json index 200602b..d0e3886 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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" + } } } } diff --git a/server/package.json b/server/package.json index 6f70345..7b62b6f 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/server/src/app.js b/server/src/app.js index beecc3b..73d3b1a 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -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. diff --git a/server/src/middleware/botScore.js b/server/src/middleware/botScore.js new file mode 100644 index 0000000..259261b --- /dev/null +++ b/server/src/middleware/botScore.js @@ -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, +} diff --git a/server/src/middleware/loginProtection.js b/server/src/middleware/loginProtection.js new file mode 100644 index 0000000..0bcabbe --- /dev/null +++ b/server/src/middleware/loginProtection.js @@ -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, +} diff --git a/server/src/model/users/users.db.js b/server/src/model/users/users.db.js index 77058c9..14595b7 100644 --- a/server/src/model/users/users.db.js +++ b/server/src/model/users/users.db.js @@ -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, } diff --git a/server/src/model/users/users.model.js b/server/src/model/users/users.model.js index 5cc145a..ae0b45e 100644 --- a/server/src/model/users/users.model.js +++ b/server/src/model/users/users.model.js @@ -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, } diff --git a/server/src/router/v1/admin/account.controller.js b/server/src/router/v1/admin/account.controller.js new file mode 100644 index 0000000..bce172b --- /dev/null +++ b/server/src/router/v1/admin/account.controller.js @@ -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 } diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index f53c0c1..988d957 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -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') diff --git a/server/src/router/v1/auth/auth.controller.js b/server/src/router/v1/auth/auth.controller.js index 914ffc9..54afcd9 100644 --- a/server/src/router/v1/auth/auth.controller.js +++ b/server/src/router/v1/auth/auth.controller.js @@ -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') +// 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 } }) +} + 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({ message: 'Incorrect username or password.' }) + return res.status(401).json(GENERIC_FAIL) } - 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 }) + // 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 res.json({ - user: { id: user.id, username: user.username, role: user.role }, - }) + 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 } diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js index 13fd84e..77c9ed4 100644 --- a/server/src/router/v1/auth/auth.routes.js +++ b/server/src/router/v1/auth/auth.routes.js @@ -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) diff --git a/server/src/server.js b/server/src/server.js index e34aab4..5b7b898 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -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() diff --git a/server/src/utils/auth.js b/server/src/utils/auth.js index 2749216..f8f7c62 100644 --- a/server/src/utils/auth.js +++ b/server/src/utils/auth.js @@ -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, diff --git a/server/src/utils/totp.js b/server/src/utils/totp.js new file mode 100644 index 0000000..b928d6e --- /dev/null +++ b/server/src/utils/totp.js @@ -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 . +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 } diff --git a/server/src/utils/trustProxy.js b/server/src/utils/trustProxy.js new file mode 100644 index 0000000..d9f67bb --- /dev/null +++ b/server/src/utils/trustProxy.js @@ -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 } diff --git a/server/test/_helper.js b/server/test/_helper.js new file mode 100644 index 0000000..3baf6c8 --- /dev/null +++ b/server/test/_helper.js @@ -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 } diff --git a/server/test/botScore.test.js b/server/test/botScore.test.js new file mode 100644 index 0000000..ac5d6a7 --- /dev/null +++ b/server/test/botScore.test.js @@ -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() + } +}) diff --git a/server/test/honeypot.test.js b/server/test/honeypot.test.js new file mode 100644 index 0000000..974f301 --- /dev/null +++ b/server/test/honeypot.test.js @@ -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) +}) diff --git a/server/test/loginProtection.test.js b/server/test/loginProtection.test.js new file mode 100644 index 0000000..5d10b41 --- /dev/null +++ b/server/test/loginProtection.test.js @@ -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() + } +}) diff --git a/server/test/totp.test.js b/server/test/totp.test.js new file mode 100644 index 0000000..abc9bf7 --- /dev/null +++ b/server/test/totp.test.js @@ -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) +}) diff --git a/server/test/trustProxy.test.js b/server/test/trustProxy.test.js new file mode 100644 index 0000000..e2d5939 --- /dev/null +++ b/server/test/trustProxy.test.js @@ -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() + } +})
@@ -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'}
Protected area — not indexed. Sessions expire after 1 day. diff --git a/client/src/routes/admin/views/AccountAdmin.jsx b/client/src/routes/admin/views/AccountAdmin.jsx new file mode 100644 index 0000000..ab0fbca --- /dev/null +++ b/client/src/routes/admin/views/AccountAdmin.jsx @@ -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 + if (error) return + + 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 ( + + + Two-factor authentication + + + 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. + + + + + {enabled ? 'Enabled' : 'Not enabled'} + + + {/* Enable flow */} + {!enabled && !setup && ( + + + {busy ? 'Preparing…' : 'Set up two-factor'} + + + )} + + {!enabled && setup && ( + + + 1. Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm. + + + + Verification code + setCode(e.target.value)} + className="input" + /> + + + + {busy ? 'Enabling…' : 'Confirm & enable'} + + setSetup(null)} disabled={busy} className="pill"> + Cancel + + + + )} + + {/* Disable flow */} + {enabled && ( + + + Enter a current code from your authenticator to turn two-factor off. + + + Verification code + setCode(e.target.value)} + className="input" + /> + + + + {busy ? 'Disabling…' : 'Disable two-factor'} + + + + )} + + {msg && {msg}} + {error && {error}} + + ) +} diff --git a/server/.env.example b/server/.env.example index b2dd4e0..1fff7f0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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). +# -> 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 diff --git a/server/db/schema.sql b/server/db/schema.sql index 5caaf31..9c74697 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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; diff --git a/server/package-lock.json b/server/package-lock.json index 200602b..d0e3886 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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" + } } } } diff --git a/server/package.json b/server/package.json index 6f70345..7b62b6f 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/server/src/app.js b/server/src/app.js index beecc3b..73d3b1a 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -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. diff --git a/server/src/middleware/botScore.js b/server/src/middleware/botScore.js new file mode 100644 index 0000000..259261b --- /dev/null +++ b/server/src/middleware/botScore.js @@ -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, +} diff --git a/server/src/middleware/loginProtection.js b/server/src/middleware/loginProtection.js new file mode 100644 index 0000000..0bcabbe --- /dev/null +++ b/server/src/middleware/loginProtection.js @@ -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, +} diff --git a/server/src/model/users/users.db.js b/server/src/model/users/users.db.js index 77058c9..14595b7 100644 --- a/server/src/model/users/users.db.js +++ b/server/src/model/users/users.db.js @@ -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, } diff --git a/server/src/model/users/users.model.js b/server/src/model/users/users.model.js index 5cc145a..ae0b45e 100644 --- a/server/src/model/users/users.model.js +++ b/server/src/model/users/users.model.js @@ -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, } diff --git a/server/src/router/v1/admin/account.controller.js b/server/src/router/v1/admin/account.controller.js new file mode 100644 index 0000000..bce172b --- /dev/null +++ b/server/src/router/v1/admin/account.controller.js @@ -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 } diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index f53c0c1..988d957 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -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') diff --git a/server/src/router/v1/auth/auth.controller.js b/server/src/router/v1/auth/auth.controller.js index 914ffc9..54afcd9 100644 --- a/server/src/router/v1/auth/auth.controller.js +++ b/server/src/router/v1/auth/auth.controller.js @@ -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') +// 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 } }) +} + 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({ message: 'Incorrect username or password.' }) + return res.status(401).json(GENERIC_FAIL) } - 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 }) + // 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 res.json({ - user: { id: user.id, username: user.username, role: user.role }, - }) + 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 } diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js index 13fd84e..77c9ed4 100644 --- a/server/src/router/v1/auth/auth.routes.js +++ b/server/src/router/v1/auth/auth.routes.js @@ -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) diff --git a/server/src/server.js b/server/src/server.js index e34aab4..5b7b898 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -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() diff --git a/server/src/utils/auth.js b/server/src/utils/auth.js index 2749216..f8f7c62 100644 --- a/server/src/utils/auth.js +++ b/server/src/utils/auth.js @@ -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, diff --git a/server/src/utils/totp.js b/server/src/utils/totp.js new file mode 100644 index 0000000..b928d6e --- /dev/null +++ b/server/src/utils/totp.js @@ -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 . +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 } diff --git a/server/src/utils/trustProxy.js b/server/src/utils/trustProxy.js new file mode 100644 index 0000000..d9f67bb --- /dev/null +++ b/server/src/utils/trustProxy.js @@ -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 } diff --git a/server/test/_helper.js b/server/test/_helper.js new file mode 100644 index 0000000..3baf6c8 --- /dev/null +++ b/server/test/_helper.js @@ -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 } diff --git a/server/test/botScore.test.js b/server/test/botScore.test.js new file mode 100644 index 0000000..ac5d6a7 --- /dev/null +++ b/server/test/botScore.test.js @@ -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() + } +}) diff --git a/server/test/honeypot.test.js b/server/test/honeypot.test.js new file mode 100644 index 0000000..974f301 --- /dev/null +++ b/server/test/honeypot.test.js @@ -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) +}) diff --git a/server/test/loginProtection.test.js b/server/test/loginProtection.test.js new file mode 100644 index 0000000..5d10b41 --- /dev/null +++ b/server/test/loginProtection.test.js @@ -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() + } +}) diff --git a/server/test/totp.test.js b/server/test/totp.test.js new file mode 100644 index 0000000..abc9bf7 --- /dev/null +++ b/server/test/totp.test.js @@ -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) +}) diff --git a/server/test/trustProxy.test.js b/server/test/trustProxy.test.js new file mode 100644 index 0000000..e2d5939 --- /dev/null +++ b/server/test/trustProxy.test.js @@ -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() + } +})
+ 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. +
+ 1. Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm. +
+ Enter a current code from your authenticator to turn two-factor off. +
{msg}
{error}