domma-cms 0.82.0 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/templates/login.html +16 -0
- package/admin/js/views/login.js +2 -2
- package/bin/update.js +7 -3
- package/config/plugins.json +5 -0
- package/package.json +2 -1
- package/plugins/free-tier.lock.json +25 -1
- package/plugins/security/CLAUDE.md +41 -0
- package/plugins/security/admin/lib/blocks.js +46 -0
- package/plugins/security/admin/lib/health.js +148 -0
- package/plugins/security/admin/lib/ip.js +44 -0
- package/plugins/security/admin/lib/lockout.js +78 -0
- package/plugins/security/admin/lib/policy.js +32 -0
- package/plugins/security/admin/lib/probes.js +39 -0
- package/plugins/security/admin/lib/settings.js +86 -0
- package/plugins/security/admin/templates/security.html +170 -0
- package/plugins/security/admin/views/security.js +529 -0
- package/plugins/security/bin/unlock.js +81 -0
- package/plugins/security/config.js +7 -0
- package/plugins/security/plugin.js +303 -0
- package/plugins/security/plugin.json +46 -0
- package/plugins/security/server/common-passwords.NOTICE +3 -0
- package/plugins/security/server/common-passwords.txt +10001 -0
- package/plugins/security/server/facts.js +133 -0
- package/plugins/security/server/state.js +106 -0
- package/plugins/security/tests/security.test.js +134 -0
- package/server/routes/api/auth.js +180 -39
- package/server/routes/api/users.js +20 -0
- package/server/server.js +25 -7
- package/server/services/audit.js +63 -0
- package/server/services/authChecks.js +128 -0
- package/server/services/network.js +49 -0
- package/server/services/plugins.js +5 -1
- package/server/services/sessions.js +171 -0
- package/server/services/siteGitignore.js +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - blocking an address: manual rules (ip.js syntax) and temporary
|
|
3
|
+
* blocks (an address that kept probing). An address that signed in recently is
|
|
4
|
+
* "trusted": probing never blocks it (an admin mistyping /wp-admin must not lock
|
|
5
|
+
* themselves out) - a manual rule still does. Loopback is never blocked. Pure.
|
|
6
|
+
*/
|
|
7
|
+
import {ipMatches, isLoopbackIp, plainIp} from './ip.js';
|
|
8
|
+
|
|
9
|
+
export const TRUST_DAYS = 7;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Why an address is blocked, or null.
|
|
13
|
+
*
|
|
14
|
+
* @param {{rules: {rule: string}[], temp: Object<string, {until: number, reason: string}>}} state
|
|
15
|
+
*/
|
|
16
|
+
export function blockedBy(state, ip, now = Date.now()) {
|
|
17
|
+
const a = plainIp(ip);
|
|
18
|
+
if (!a || isLoopbackIp(a)) return null;
|
|
19
|
+
const rule = (state.rules || []).find(r => ipMatches(a, [r.rule]));
|
|
20
|
+
if (rule) return {kind: 'rule', rule: rule.rule, reason: rule.note || 'Blocked by hand'};
|
|
21
|
+
const t = state.temp?.[a];
|
|
22
|
+
if (t && t.until > now) return {kind: 'temp', until: t.until, reason: t.reason};
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const isTrusted = (state, ip, now = Date.now()) => (state.trusted?.[plainIp(ip)] || 0) > now - TRUST_DAYS * 86_400_000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Count a probe from an address (in `recent`, a Map kept in memory). True when
|
|
30
|
+
* it should now be blocked.
|
|
31
|
+
*/
|
|
32
|
+
export function recordProbe(recent, ip, {now = Date.now(), blockAfter = 5, withinMin = 10} = {}) {
|
|
33
|
+
const a = plainIp(ip);
|
|
34
|
+
const list = (recent.get(a) || []).filter(t => t > now - withinMin * 60_000);
|
|
35
|
+
list.push(now);
|
|
36
|
+
recent.set(a, list);
|
|
37
|
+
if (recent.size > 10_000) recent.delete(recent.keys().next().value);
|
|
38
|
+
return list.length >= blockAfter;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Forget ended temporary blocks and old trust. */
|
|
42
|
+
export function pruneBlocks(state, now = Date.now()) {
|
|
43
|
+
for (const [ip, t] of Object.entries(state.temp || {})) if (t.until < now) delete state.temp[ip];
|
|
44
|
+
for (const [ip, at] of Object.entries(state.trusted || {})) if (at < now - TRUST_DAYS * 86_400_000) delete state.trusted[ip];
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - the health check: facts about the site in, checks out. Pure; the
|
|
3
|
+
* server gathers the facts (server/facts.js). Each check says what it found,
|
|
4
|
+
* why it matters and how to fix it - a check that cannot be answered is 'info',
|
|
5
|
+
* never a pass.
|
|
6
|
+
*
|
|
7
|
+
* status: 'pass' | 'warn' | 'fail' | 'info'; `critical` makes a fail red in the badge.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DAY = 86_400_000;
|
|
11
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
12
|
+
const names = (list) => (list.length <= 3 ? list.join(', ') : `${list.slice(0, 3).join(', ')} and ${list.length - 3} more`);
|
|
13
|
+
|
|
14
|
+
function semverBehind(installed, latest) {
|
|
15
|
+
const a = String(installed || '').split('.').map(Number);
|
|
16
|
+
const b = String(latest || '').split('.').map(Number);
|
|
17
|
+
if (a.length < 3 || b.length < 3 || [...a, ...b].some(Number.isNaN)) return null;
|
|
18
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? {major: i === 0, minor: i === 1, patch: i === 2} : false;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} f - facts (see server/facts.js)
|
|
24
|
+
* @param {{now?: number, staleAdminDays?: number}} [opts]
|
|
25
|
+
* @returns {{checks: object[], score: number, failing: number, critical: number}}
|
|
26
|
+
*/
|
|
27
|
+
export function evaluate(f = {}, {now = Date.now(), staleAdminDays = 90} = {}) {
|
|
28
|
+
const checks = [];
|
|
29
|
+
const add = (c) => checks.push({critical: false, link: '', ...c});
|
|
30
|
+
|
|
31
|
+
// --- People ---
|
|
32
|
+
const admins = f.admins || [];
|
|
33
|
+
const stale = admins.filter(a => !a.lastLogin || Date.parse(a.lastLogin) < now - staleAdminDays * DAY);
|
|
34
|
+
add({id: 'admins-stale', group: 'People', title: 'Unused admin accounts',
|
|
35
|
+
status: !admins.length ? 'info' : stale.length ? 'warn' : 'pass',
|
|
36
|
+
detail: stale.length ? `${plural(stale.length, 'admin')} not signed in for ${staleAdminDays}+ days: ${names(stale.map(a => a.name || a.email))}.` : `Every admin has signed in within ${staleAdminDays} days.`,
|
|
37
|
+
why: 'An admin account nobody uses is one nobody would notice being used by someone else.',
|
|
38
|
+
fix: 'Switch off or remove admin accounts that are no longer needed, or lower their role.', link: '#/users'});
|
|
39
|
+
add({id: 'admins-count', group: 'People', title: 'How many admins',
|
|
40
|
+
status: admins.length > 5 ? 'warn' : admins.length ? 'pass' : 'info',
|
|
41
|
+
detail: `${plural(admins.length, 'admin')} (admin or super admin).`,
|
|
42
|
+
why: 'Every admin can change everything. Fewer admins, fewer ways in.',
|
|
43
|
+
fix: 'Give people the lowest role that lets them do their job.', link: '#/users'});
|
|
44
|
+
const never = (f.users || []).filter(u => !u.lastLogin && u.createdAt && Date.parse(u.createdAt) < now - 30 * DAY);
|
|
45
|
+
add({id: 'users-never', group: 'People', title: 'Accounts never used',
|
|
46
|
+
status: never.length ? 'warn' : 'pass',
|
|
47
|
+
detail: never.length ? `${plural(never.length, 'account')} made over 30 days ago and never signed in: ${names(never.map(u => u.name || u.email))}.` : 'Every account older than 30 days has been used.',
|
|
48
|
+
why: 'An account with a password nobody changed is an easy one to guess.',
|
|
49
|
+
fix: 'Remove accounts nobody uses.', link: '#/users'});
|
|
50
|
+
|
|
51
|
+
// --- Sign-in ---
|
|
52
|
+
add({id: 'lockout', group: 'Sign-in', title: 'Sign-in lockout',
|
|
53
|
+
status: f.protections?.checks === false ? 'fail' : f.protections?.lockout ? 'pass' : 'warn', critical: f.protections?.checks === false,
|
|
54
|
+
detail: f.protections?.checks === false ? 'This engine is too old for sign-in checks (needs Domma CMS 0.83 or later).'
|
|
55
|
+
: f.protections?.lockout ? 'Repeated wrong passwords lock the account and the address for a while.' : 'Switched off in the settings.',
|
|
56
|
+
why: 'Without it, a password can be guessed as fast as the rate limit allows, forever.',
|
|
57
|
+
fix: f.protections?.checks === false ? 'Update the CMS.' : 'Switch it on in the settings (the cog).'});
|
|
58
|
+
add({id: 'policy', group: 'Sign-in', title: 'Password rules',
|
|
59
|
+
status: f.protections?.policy ? (f.policyMin >= 10 ? 'pass' : 'warn') : 'warn',
|
|
60
|
+
detail: f.protections?.policy ? `New passwords: at least ${f.policyMin} characters, not a common one, not your name or email.` : 'Only the CMS minimum (8 characters) applies.',
|
|
61
|
+
why: 'Most break-ins use passwords that are short, common or guessable from the person.',
|
|
62
|
+
fix: 'Switch the password rules on, with at least 10 characters.'});
|
|
63
|
+
add({id: 'two-factor', group: 'Sign-in', title: 'Two-factor sign-in',
|
|
64
|
+
status: f.twoFactor ? 'pass' : 'info',
|
|
65
|
+
detail: f.twoFactor ? 'Available.' : 'Not available - Security Pro adds two-factor sign-in with an authenticator app.',
|
|
66
|
+
why: 'A stolen or guessed password alone is then not enough to get in.',
|
|
67
|
+
fix: 'Security Pro.'});
|
|
68
|
+
add({id: 'smtp', group: 'Sign-in', title: 'Email for resets and alerts',
|
|
69
|
+
status: f.smtp ? 'pass' : 'warn',
|
|
70
|
+
detail: f.smtp ? 'Email is set up.' : 'No outgoing email: password resets and alerts cannot be sent.',
|
|
71
|
+
why: 'Without email, a locked-out admin has no self-service way back, and alerts go unseen.',
|
|
72
|
+
fix: 'Set up email in Settings.', link: '#/settings'});
|
|
73
|
+
|
|
74
|
+
// --- Server ---
|
|
75
|
+
const behind = semverBehind(f.engine?.installed, f.engine?.latest);
|
|
76
|
+
add({id: 'engine', group: 'Server', title: 'Domma CMS up to date',
|
|
77
|
+
status: behind === null ? 'info' : behind ? (behind.major || behind.minor ? 'warn' : 'pass') : 'pass',
|
|
78
|
+
detail: behind === null ? `Running ${f.engine?.installed || 'an unknown version'}; the latest could not be checked.`
|
|
79
|
+
: behind ? `Running ${f.engine.installed}; ${f.engine.latest} is out.` : `Running ${f.engine.installed}, the latest.`,
|
|
80
|
+
why: 'Updates carry security fixes.', fix: 'Update the CMS (or ask whoever hosts it).'});
|
|
81
|
+
add({id: 'production', group: 'Server', title: 'Production mode',
|
|
82
|
+
status: f.nodeEnv === 'production' ? 'pass' : 'warn',
|
|
83
|
+
detail: f.nodeEnv === 'production' ? 'NODE_ENV is production.' : `NODE_ENV is "${f.nodeEnv || 'not set'}".`,
|
|
84
|
+
why: 'Outside production, errors can show more than a visitor should see.',
|
|
85
|
+
fix: 'Set NODE_ENV=production in the site\'s .env and restart.'});
|
|
86
|
+
if (f.proxy?.via) {
|
|
87
|
+
const t = f.proxy.trust;
|
|
88
|
+
add({id: 'proxy', group: 'Server', title: 'Visitor addresses',
|
|
89
|
+
status: t === false ? 'fail' : t === true ? 'warn' : 'pass', critical: t === false,
|
|
90
|
+
detail: t === false ? 'The site is behind a proxy but does not trust it: every visitor looks like the proxy.'
|
|
91
|
+
: t === true ? 'Every proxy hop is trusted (TRUST_PROXY=1): a visitor can choose the address they appear as.' : `TRUST_PROXY=${t}.`,
|
|
92
|
+
why: 'Rate limits, lockouts and blocks all go by the visitor\'s address.',
|
|
93
|
+
fix: 'Set TRUST_PROXY=loopback in the site\'s .env and restart.'});
|
|
94
|
+
}
|
|
95
|
+
if (f.bind) {
|
|
96
|
+
add({id: 'bind', group: 'Server', title: 'Listening address',
|
|
97
|
+
status: f.bind === '0.0.0.0' && f.proxy?.via ? 'warn' : 'pass',
|
|
98
|
+
detail: f.bind === '0.0.0.0' && f.proxy?.via ? 'The CMS listens on every network interface although a proxy fronts it.' : `Listening on ${f.bind}.`,
|
|
99
|
+
why: 'Reached directly, the CMS bypasses the proxy and its protections.',
|
|
100
|
+
fix: 'Set "host": "127.0.0.1" in config/server.json when a proxy sits in front.'});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Web ---
|
|
104
|
+
if (f.cert) {
|
|
105
|
+
const d = f.cert.daysLeft;
|
|
106
|
+
add({id: 'certificate', group: 'Web', title: 'HTTPS certificate',
|
|
107
|
+
status: d === null || d === undefined ? 'info' : d < 7 ? 'fail' : d < 21 ? 'warn' : 'pass', critical: d !== null && d < 7,
|
|
108
|
+
detail: d === null || d === undefined ? `Could not read the certificate${f.cert.error ? `: ${f.cert.error}` : ''}.` : d < 0 ? `Expired ${-d} days ago.` : `Valid for ${plural(d, 'more day')}${f.cert.issuer ? ` (${f.cert.issuer})` : ''}.`,
|
|
109
|
+
why: 'An expired certificate turns every visitor away with a warning.',
|
|
110
|
+
fix: 'Renew it (Site Manager renews automatically on the fleet).'});
|
|
111
|
+
}
|
|
112
|
+
const h = f.headers;
|
|
113
|
+
if (h) {
|
|
114
|
+
const missing = [['csp', 'Content-Security-Policy'], ['nosniff', 'X-Content-Type-Options'], ['frame', 'frame protection'],
|
|
115
|
+
['referrer', 'Referrer-Policy'], ...(h.https ? [['hsts', 'Strict-Transport-Security']] : [])].filter(([k]) => !h[k]).map(([, n]) => n);
|
|
116
|
+
add({id: 'headers', group: 'Web', title: 'Security headers',
|
|
117
|
+
status: h.error ? 'info' : missing.length ? 'warn' : 'pass',
|
|
118
|
+
detail: h.error ? `Could not fetch the home page: ${h.error}.` : missing.length ? `Missing: ${missing.join(', ')}.` : 'All present.',
|
|
119
|
+
why: 'They stop pages being framed, content being mis-typed and scripts being injected.',
|
|
120
|
+
fix: 'The CMS sets them; missing ones are usually stripped by a proxy in front.'});
|
|
121
|
+
add({id: 'https', group: 'Web', title: 'HTTPS',
|
|
122
|
+
status: h.error ? 'info' : h.https ? 'pass' : 'warn',
|
|
123
|
+
detail: h.https ? 'The site is served over HTTPS.' : 'The site was reached over plain HTTP.',
|
|
124
|
+
why: 'Without it, passwords and sessions cross the network readable.', fix: 'Serve the site over HTTPS only.'});
|
|
125
|
+
}
|
|
126
|
+
const exposed = (f.probeFiles || []).filter(p => p.status === 200);
|
|
127
|
+
add({id: 'exposed', group: 'Web', title: 'Private files not served',
|
|
128
|
+
status: !(f.probeFiles || []).length ? 'info' : exposed.length ? 'fail' : 'pass', critical: exposed.length > 0,
|
|
129
|
+
detail: exposed.length ? `Served to anyone: ${exposed.map(p => p.path).join(', ')}.` : `${plural((f.probeFiles || []).length, 'private path')} checked - none served.`,
|
|
130
|
+
why: 'These hold secrets (keys, passwords, the code\'s history).',
|
|
131
|
+
fix: 'Remove the file from the site, or block it in the web server.'});
|
|
132
|
+
|
|
133
|
+
// --- Plugins ---
|
|
134
|
+
const lapsed = (f.licences || []).filter(l => !['valid', 'grace', 'support-ended', 'not-required'].includes(l.state));
|
|
135
|
+
add({id: 'licences', group: 'Plugins', title: 'Plugin licences',
|
|
136
|
+
status: lapsed.length ? 'warn' : 'pass',
|
|
137
|
+
detail: lapsed.length ? `Not loaded for want of a licence: ${names(lapsed.map(l => l.name))}.` : 'Every licensed plugin loaded.',
|
|
138
|
+
why: 'A plugin that does not load does not protect or do what you think it does.', fix: 'Renew or remove it.', link: '#/plugins'});
|
|
139
|
+
|
|
140
|
+
const scored = checks.filter(c => c.status !== 'info');
|
|
141
|
+
const points = scored.reduce((a, c) => a + (c.status === 'pass' ? 1 : c.status === 'warn' ? 0.5 : 0), 0);
|
|
142
|
+
return {
|
|
143
|
+
checks,
|
|
144
|
+
score: scored.length ? Math.round((points / scored.length) * 100) : 0,
|
|
145
|
+
failing: checks.filter(c => c.status === 'fail' || c.status === 'warn').length,
|
|
146
|
+
critical: checks.filter(c => c.status === 'fail' && c.critical).length
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - address rules: an address (81.2.69.142), a trailing wildcard
|
|
3
|
+
* (192.168.1.*), an IPv4 CIDR (10.0.0.0/8) or an IPv6 prefix (2a00:23c7:*).
|
|
4
|
+
* Pure. Copied from Analytics Pro (admin/lib/settings.js) - keep them alike.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Is this a rule we understand? */
|
|
8
|
+
export function ipRuleValid(rule) {
|
|
9
|
+
const r = String(rule).trim();
|
|
10
|
+
if (/^(\d{1,3}\.){3}\d{1,3}\/(\d|[12]\d|3[0-2])$/.test(r)) return r.split('/')[0].split('.').every(o => +o <= 255);
|
|
11
|
+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(r)) return r.split('.').every(o => +o <= 255);
|
|
12
|
+
if (/^(\d{1,3}\.){1,3}\*$/.test(r)) return true;
|
|
13
|
+
if (/^[0-9a-f:]+(\*)?$/i.test(r) && r.includes(':')) return true;
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const v4num = (ip) => ip.split('.').reduce((n, o) => n * 256 + (+o), 0);
|
|
18
|
+
|
|
19
|
+
/** The address as rules see it: no IPv4-mapped prefix, lower case. */
|
|
20
|
+
export const plainIp = (ip) => String(ip ?? '').trim().replace(/^::ffff:(?=\d+\.)/i, '').toLowerCase();
|
|
21
|
+
|
|
22
|
+
/** Does an address match one of the rules? */
|
|
23
|
+
export function ipMatches(ip, rules = []) {
|
|
24
|
+
const a = plainIp(ip);
|
|
25
|
+
if (!a) return false;
|
|
26
|
+
for (const raw of rules) {
|
|
27
|
+
const r = String(raw).trim().toLowerCase();
|
|
28
|
+
if (!r) continue;
|
|
29
|
+
if (r.endsWith('*')) { if (a.startsWith(r.slice(0, -1))) return true; continue; }
|
|
30
|
+
if (r.includes('/')) {
|
|
31
|
+
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(a)) continue;
|
|
32
|
+
const [base, bits] = r.split('/');
|
|
33
|
+
const size = 2 ** (32 - Number(bits));
|
|
34
|
+
if (Math.floor(v4num(a) / size) === Math.floor(v4num(base) / size)) return true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (a === r) return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const LOOPBACK = /^(127\.\d+\.\d+\.\d+|::1)$/;
|
|
43
|
+
/** Loopback: this machine. Never blocked or locked out. */
|
|
44
|
+
export const isLoopbackIp = (ip) => LOOPBACK.test(plainIp(ip));
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - sign-in lockout. Failures are counted per ACCOUNT and per ADDRESS
|
|
3
|
+
* inside a window; reaching the threshold locks that key, first for `baseMin`,
|
|
4
|
+
* each further lock twice as long, up to `maxMin`. A success clears the account
|
|
5
|
+
* (not the address - an attacker with one right password should not wipe their
|
|
6
|
+
* address's record). Pure: the state is a plain object the plugin saves.
|
|
7
|
+
*
|
|
8
|
+
* state: {[key]: {fails: number[] (ms), locks: number, until: number}}
|
|
9
|
+
* keys: 'a:<email>' and 'i:<address>'
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_LOCKOUT = {
|
|
13
|
+
accountThreshold: 5, // failures on one account...
|
|
14
|
+
addressThreshold: 20, // ...or from one address (offices share one)...
|
|
15
|
+
windowMin: 15, // ...within this many minutes
|
|
16
|
+
baseMin: 1, // first lock
|
|
17
|
+
maxMin: 60 // longest lock
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const accountKey = (email) => `a:${String(email ?? '').trim().toLowerCase()}`;
|
|
21
|
+
export const addressKey = (ip) => `i:${String(ip ?? '').trim().toLowerCase()}`;
|
|
22
|
+
|
|
23
|
+
/** Until when a key is locked (ms), or 0. */
|
|
24
|
+
export function lockedUntil(state, key, now = Date.now()) {
|
|
25
|
+
const e = state[key];
|
|
26
|
+
return e && e.until > now ? e.until : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Count a failure against a key; lock it when the threshold is reached.
|
|
31
|
+
*
|
|
32
|
+
* @returns {number} until when it is now locked (ms), or 0
|
|
33
|
+
*/
|
|
34
|
+
export function recordFailure(state, key, {now = Date.now(), threshold, windowMin, baseMin, maxMin} = {}) {
|
|
35
|
+
const cfg = {...DEFAULT_LOCKOUT, windowMin: windowMin ?? DEFAULT_LOCKOUT.windowMin, baseMin: baseMin ?? DEFAULT_LOCKOUT.baseMin, maxMin: maxMin ?? DEFAULT_LOCKOUT.maxMin};
|
|
36
|
+
const limit = threshold ?? (key.startsWith('i:') ? cfg.addressThreshold : cfg.accountThreshold);
|
|
37
|
+
const e = state[key] ??= {fails: [], locks: 0, until: 0};
|
|
38
|
+
e.fails = e.fails.filter(t => t > now - cfg.windowMin * 60_000);
|
|
39
|
+
e.fails.push(now);
|
|
40
|
+
if (e.fails.length >= limit) {
|
|
41
|
+
const minutes = Math.min(cfg.maxMin, cfg.baseMin * 2 ** e.locks);
|
|
42
|
+
e.locks++;
|
|
43
|
+
e.until = now + minutes * 60_000;
|
|
44
|
+
e.fails = [];
|
|
45
|
+
return e.until;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A successful sign-in: the account starts afresh. */
|
|
51
|
+
export function recordSuccess(state, key) {
|
|
52
|
+
delete state[key];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Drop what no longer matters (no recent failure, no lock, locks forgotten after a day). */
|
|
56
|
+
export function pruneLockouts(state, {now = Date.now(), windowMin = DEFAULT_LOCKOUT.windowMin} = {}) {
|
|
57
|
+
for (const [k, e] of Object.entries(state)) {
|
|
58
|
+
e.fails = (e.fails || []).filter(t => t > now - windowMin * 60_000);
|
|
59
|
+
if (!e.fails.length && e.until < now - 24 * 3_600_000) delete state[k];
|
|
60
|
+
}
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Live locks, soonest-ending last: [{key, kind, who, until}]. */
|
|
65
|
+
export function activeLocks(state, now = Date.now()) {
|
|
66
|
+
return Object.entries(state)
|
|
67
|
+
.filter(([, e]) => e.until > now)
|
|
68
|
+
.map(([key, e]) => ({key, kind: key.startsWith('a:') ? 'account' : 'address', who: key.slice(2), until: e.until, locks: e.locks}))
|
|
69
|
+
.sort((a, b) => b.until - a.until);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** "in 3 minutes" / "in 1 hour" - for the refusal message. */
|
|
73
|
+
export function retryText(until, now = Date.now()) {
|
|
74
|
+
const min = Math.max(1, Math.ceil((until - now) / 60_000));
|
|
75
|
+
if (min < 60) return `in ${min} minute${min === 1 ? '' : 's'}`;
|
|
76
|
+
const h = Math.ceil(min / 60);
|
|
77
|
+
return `in ${h} hour${h === 1 ? '' : 's'}`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - the password policy. Pure: the common-password list is handed in
|
|
3
|
+
* (the server loads server/common-passwords.txt once).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_POLICY = {minLength: 10, blockCommon: true, blockPersonal: true};
|
|
7
|
+
|
|
8
|
+
const norm = (s) => String(s ?? '').toLowerCase();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What is wrong with a password, as sentences ([] = fine).
|
|
12
|
+
*
|
|
13
|
+
* @param {string} password
|
|
14
|
+
* @param {{email?: string, name?: string}} who
|
|
15
|
+
* @param {{minLength?: number, blockCommon?: boolean, blockPersonal?: boolean, common?: Set<string>}} policy
|
|
16
|
+
*/
|
|
17
|
+
export function passwordProblems(password, {email = '', name = ''} = {}, {minLength = DEFAULT_POLICY.minLength, blockCommon = true, blockPersonal = true, common = new Set()} = {}) {
|
|
18
|
+
const pw = String(password ?? '');
|
|
19
|
+
const out = [];
|
|
20
|
+
if (pw.length < minLength) out.push(`Use at least ${minLength} characters.`);
|
|
21
|
+
const low = norm(pw);
|
|
22
|
+
if (blockCommon && (common.has(low) || common.has(low.replace(/[\d!?.]+$/, '')))) {
|
|
23
|
+
out.push('That password is one of the most common - pick something less guessable.');
|
|
24
|
+
}
|
|
25
|
+
if (blockPersonal) {
|
|
26
|
+
const local = norm(email).split('@')[0];
|
|
27
|
+
const parts = [local, ...norm(name).split(/\s+/)].filter(p => p && p.length >= 3);
|
|
28
|
+
if (parts.some(p => low.includes(p))) out.push('Leave your name and email out of your password.');
|
|
29
|
+
}
|
|
30
|
+
if (pw.length >= 4 && new Set(pw).size <= 2) out.push('Use more than one or two different characters.');
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - probe paths: what scanners ask for on every site they find, and a
|
|
3
|
+
* Domma CMS never serves. Answered with an empty 404 before any rendering, and
|
|
4
|
+
* counted: an address that keeps probing is blocked for a while. Pure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Path prefixes and patterns, lower-cased; a trailing / matches everything under it. */
|
|
8
|
+
export const DEFAULT_PROBES = [
|
|
9
|
+
'/wp-login.php', '/wp-admin/', '/wp-content/', '/wp-includes/', '/wp-json/', '/xmlrpc.php', '/wordpress/',
|
|
10
|
+
'/.env', '/.git/', '/.svn/', '/.hg/', '/.ds_store', '/.htaccess', '/.htpasswd', '/.aws/', '/.ssh/', '/.vscode/', '/.idea/',
|
|
11
|
+
'/phpmyadmin/', '/pma/', '/myadmin/', '/adminer.php', '/phpinfo.php', '/info.php', '/server-status', '/server-info',
|
|
12
|
+
'/cgi-bin/', '/vendor/phpunit/', '/boaform/', '/hnap1', '/actuator/', '/solr/', '/owa/', '/ecp/', '/autodiscover/',
|
|
13
|
+
'/config.json', '/config.yml', '/web.config', '/backup.zip', '/backup.sql', '/database.sql', '/dump.sql', '/db.sql',
|
|
14
|
+
'/debug/default/view', '/telescope/', '/_ignition/', '/api/jsonws/', '/remote/login', '/global-protect/', '/sslvpn'
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** Extensions no Domma CMS page has: a request ending so is a probe. */
|
|
18
|
+
export const PROBE_EXTENSIONS = ['.php', '.asp', '.aspx', '.jsp', '.cgi', '.pl', '.bak', '.old', '.sql', '.swp', '.env'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Is this path a probe?
|
|
22
|
+
*
|
|
23
|
+
* @param {string} path - the request path, without the query
|
|
24
|
+
* @param {{extra?: string[], allow?: string[]}} [opts] - the site's own additions; paths it DOES serve
|
|
25
|
+
*/
|
|
26
|
+
export function isProbe(path, {extra = [], allow = []} = {}) {
|
|
27
|
+
let p;
|
|
28
|
+
try { p = decodeURIComponent(String(path || '')).toLowerCase(); } catch { p = String(path || '').toLowerCase(); }
|
|
29
|
+
p = p.split('?')[0].replace(/\/{2,}/g, '/');
|
|
30
|
+
if (!p.startsWith('/')) p = `/${p}`;
|
|
31
|
+
if (allow.some(a => a && p.startsWith(String(a).toLowerCase()))) return false;
|
|
32
|
+
// A traversal attempt is a probe whatever it asks for.
|
|
33
|
+
if (p.includes('/../') || p.endsWith('/..')) return true;
|
|
34
|
+
const list = [...DEFAULT_PROBES, ...extra.map(e => String(e).toLowerCase()).filter(Boolean)];
|
|
35
|
+
for (const rule of list) {
|
|
36
|
+
if (rule.endsWith('/') ? (p === rule.slice(0, -1) || p.startsWith(rule)) : (p === rule || p.startsWith(`${rule}/`))) return true;
|
|
37
|
+
}
|
|
38
|
+
return PROBE_EXTENSIONS.some(ext => p.endsWith(ext));
|
|
39
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - settings (config/plugins.json `security.settings`), checked the
|
|
3
|
+
* same way by the server and the settings slideover. Pure.
|
|
4
|
+
*/
|
|
5
|
+
import {DEFAULT_LOCKOUT} from './lockout.js';
|
|
6
|
+
import {DEFAULT_POLICY} from './policy.js';
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_SETTINGS = {
|
|
9
|
+
lockout: {...DEFAULT_LOCKOUT, enabled: true},
|
|
10
|
+
policy: {...DEFAULT_POLICY, enabled: true},
|
|
11
|
+
probes: {enabled: true, blockAfter: 5, withinMin: 10, blockForMin: 60, extra: [], allow: []},
|
|
12
|
+
signinDays: 30,
|
|
13
|
+
staleAdminDays: 90,
|
|
14
|
+
notify: {lockouts: true, successAfterFails: true, probeBlocks: false}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const int = (v, min, max) => {
|
|
18
|
+
const n = Number(v);
|
|
19
|
+
return Number.isInteger(n) && n >= min && n <= max ? n : null;
|
|
20
|
+
};
|
|
21
|
+
const bool = (v, d) => (v === undefined ? d : v === true || v === 'true');
|
|
22
|
+
const lines = (v) => (Array.isArray(v) ? v : String(v ?? '').split(/[\n,]+/)).map(s => String(s).trim()).filter(Boolean);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The stored settings merged over the defaults, every value checked.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} input - a full or partial settings object
|
|
28
|
+
* @param {object} [base] - what is stored now (defaults when absent)
|
|
29
|
+
* @returns {{errors: Record<string, string>, settings: object}}
|
|
30
|
+
*/
|
|
31
|
+
export function checkSettings(input = {}, base = DEFAULT_SETTINGS) {
|
|
32
|
+
const errors = {};
|
|
33
|
+
const b = mergeDefaults(base);
|
|
34
|
+
const out = JSON.parse(JSON.stringify(b));
|
|
35
|
+
const num = (group, key, min, max, label) => {
|
|
36
|
+
const v = input?.[group]?.[key];
|
|
37
|
+
if (v === undefined) return;
|
|
38
|
+
const n = int(v, min, max);
|
|
39
|
+
if (n === null) errors[`${group}.${key}`] = `${label}: a whole number, ${min} to ${max}.`;
|
|
40
|
+
else out[group][key] = n;
|
|
41
|
+
};
|
|
42
|
+
for (const g of ['lockout', 'policy', 'probes']) if (input?.[g]?.enabled !== undefined) out[g].enabled = bool(input[g].enabled, true);
|
|
43
|
+
num('lockout', 'accountThreshold', 3, 50, 'Failures on one account');
|
|
44
|
+
num('lockout', 'addressThreshold', 5, 500, 'Failures from one address');
|
|
45
|
+
num('lockout', 'windowMin', 1, 1440, 'Counted over (minutes)');
|
|
46
|
+
num('lockout', 'baseMin', 1, 60, 'First lock (minutes)');
|
|
47
|
+
num('lockout', 'maxMin', 1, 10080, 'Longest lock (minutes)');
|
|
48
|
+
num('policy', 'minLength', 8, 64, 'Shortest password');
|
|
49
|
+
for (const k of ['blockCommon', 'blockPersonal']) if (input?.policy?.[k] !== undefined) out.policy[k] = bool(input.policy[k], true);
|
|
50
|
+
num('probes', 'blockAfter', 1, 100, 'Probes before a block');
|
|
51
|
+
num('probes', 'withinMin', 1, 1440, 'Probes counted over (minutes)');
|
|
52
|
+
num('probes', 'blockForMin', 1, 10080, 'Block for (minutes)');
|
|
53
|
+
for (const k of ['extra', 'allow']) {
|
|
54
|
+
if (input?.probes?.[k] === undefined) continue;
|
|
55
|
+
const list = lines(input.probes[k]);
|
|
56
|
+
const bad = list.filter(p => !p.startsWith('/'));
|
|
57
|
+
if (bad.length) errors[`probes.${k}`] = `Each starts with "/": ${bad.slice(0, 3).join(', ')}`;
|
|
58
|
+
else out.probes[k] = [...new Set(list)].slice(0, 200);
|
|
59
|
+
}
|
|
60
|
+
if (out.lockout.maxMin < out.lockout.baseMin) errors['lockout.maxMin'] = 'The longest lock cannot be shorter than the first.';
|
|
61
|
+
if (input.signinDays !== undefined) {
|
|
62
|
+
const n = int(input.signinDays, 7, 365);
|
|
63
|
+
if (n === null) errors.signinDays = 'Keep the sign-in log 7 to 365 days.';
|
|
64
|
+
else out.signinDays = n;
|
|
65
|
+
}
|
|
66
|
+
if (input.staleAdminDays !== undefined) {
|
|
67
|
+
const n = int(input.staleAdminDays, 14, 730);
|
|
68
|
+
if (n === null) errors.staleAdminDays = '14 to 730 days.';
|
|
69
|
+
else out.staleAdminDays = n;
|
|
70
|
+
}
|
|
71
|
+
for (const k of Object.keys(DEFAULT_SETTINGS.notify)) if (input?.notify?.[k] !== undefined) out.notify[k] = bool(input.notify[k], true);
|
|
72
|
+
return {errors, settings: out};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stored settings (possibly old or partial) with every default filled in. */
|
|
76
|
+
export function mergeDefaults(s = {}) {
|
|
77
|
+
const d = DEFAULT_SETTINGS;
|
|
78
|
+
return {
|
|
79
|
+
lockout: {...d.lockout, ...(s.lockout || {})},
|
|
80
|
+
policy: {...d.policy, ...(s.policy || {})},
|
|
81
|
+
probes: {...d.probes, ...(s.probes || {})},
|
|
82
|
+
signinDays: s.signinDays ?? d.signinDays,
|
|
83
|
+
staleAdminDays: s.staleAdminDays ?? d.staleAdminDays,
|
|
84
|
+
notify: {...d.notify, ...(s.notify || {})}
|
|
85
|
+
};
|
|
86
|
+
}
|