domma-cms 0.87.0 → 0.88.1

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.
Files changed (47) hide show
  1. package/CLAUDE.md +9 -0
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/http-interceptor.js +1 -1
  6. package/admin/js/lib/login-arrange.js +1 -0
  7. package/admin/js/lib/password-field.js +1 -0
  8. package/admin/js/lib/password-reset-actions.js +12 -0
  9. package/admin/js/lib/plugins-arrange.js +2 -2
  10. package/admin/js/lib/site-settings-arrange.js +1 -1
  11. package/admin/js/lib/users-arrange.js +1 -1
  12. package/admin/js/templates/login.html +94 -44
  13. package/admin/js/templates/plugins.html +7 -0
  14. package/admin/js/templates/settings.html +6 -0
  15. package/admin/js/templates/user-editor.html +7 -0
  16. package/admin/js/views/login.js +1 -11
  17. package/admin/js/views/plugins.js +17 -7
  18. package/admin/js/views/settings.js +4 -4
  19. package/admin/js/views/user-editor.js +1 -1
  20. package/admin/js/views/users.js +3 -2
  21. package/package.json +1 -1
  22. package/plugins/free-tier.lock.json +13 -12
  23. package/plugins/mail-reader/CLAUDE.md +5 -0
  24. package/plugins/mail-reader/plugin.json +5 -2
  25. package/plugins/security/CLAUDE.md +7 -0
  26. package/plugins/security/admin/lib/resets.js +82 -0
  27. package/plugins/security/admin/lib/settings.js +1 -1
  28. package/plugins/security/admin/templates/security.html +3 -0
  29. package/plugins/security/admin/views/security.js +23 -10
  30. package/plugins/security/plugin.js +39 -1
  31. package/plugins/security/plugin.json +2 -2
  32. package/plugins/security/tests/security.test.js +34 -0
  33. package/server/routes/api/auth.js +41 -63
  34. package/server/routes/api/forms.js +3 -0
  35. package/server/routes/api/plugins.js +33 -3
  36. package/server/routes/api/related.js +6 -3
  37. package/server/routes/api/settings.js +12 -0
  38. package/server/routes/api/tools.js +116 -0
  39. package/server/routes/api/users.js +49 -2
  40. package/server/server.js +27 -8
  41. package/server/services/notification-sources.js +21 -1
  42. package/server/services/passwordReset.js +507 -0
  43. package/server/services/plugins.js +27 -1
  44. package/server/services/renderer.js +5 -2
  45. package/server/services/siteGitignore.js +114 -3
  46. package/server/services/tools.js +259 -0
  47. package/server/services/users.js +8 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Security - password reset requests (core's auth:resetRequested, 0.88). Pure.
3
+ *
4
+ * Each "Forgot your password?" goes into the sign-in log as `kind: 'reset'`.
5
+ * One address asking for resets of many different accounts is someone trying
6
+ * addresses, or trying to fill inboxes - that is what `recordReset` watches.
7
+ */
8
+
9
+ /** What became of a request, as the core reports it in `reason`. */
10
+ export const RESET_REASONS = {
11
+ sent: 'Link sent',
12
+ unknown: 'No such account',
13
+ inactive: 'Account switched off',
14
+ throttled: 'Held back - 3 an hour already',
15
+ 'no-smtp': 'Not sent - no mail server',
16
+ 'no-origin': 'Not sent - no Site URL',
17
+ failed: 'Email failed'
18
+ };
19
+
20
+ /**
21
+ * When it is worth telling someone. The core lets one address ask 3 times an hour
22
+ * (a route rate limit), and a person only ever asks about their own account - so
23
+ * one address using all 3 on different accounts is trying addresses. Someone
24
+ * rotating addresses gets past that, which is what the site-wide count of
25
+ * requests for accounts that do not exist is for.
26
+ */
27
+ export const RESET_PROBE = {threshold: 3, windowMin: 60};
28
+ export const RESET_UNKNOWN = {threshold: 10, windowMin: 60};
29
+
30
+ /**
31
+ * Note a reset request from an address and say whether it has just crossed the
32
+ * line: `threshold` different accounts within `windowMin`. Mutates `recent`
33
+ * (a Map of address -> [{at, email}]), dropping what is older than the window.
34
+ *
35
+ * @returns {{accounts: number, tripped: boolean}} tripped only on the request that reaches the threshold
36
+ */
37
+ export function recordReset(recent, ip, email, {now = Date.now(), threshold = RESET_PROBE.threshold, windowMin = RESET_PROBE.windowMin} = {}) {
38
+ if (!ip) return {accounts: 0, tripped: false};
39
+ const since = now - windowMin * 60_000;
40
+ const list = (recent.get(ip) || []).filter(r => r.at >= since);
41
+ const before = new Set(list.map(r => r.email)).size;
42
+ list.push({at: now, email: String(email || '').trim().toLowerCase()});
43
+ recent.set(ip, list);
44
+ const accounts = new Set(list.map(r => r.email)).size;
45
+ return {accounts, tripped: accounts >= threshold && before < threshold};
46
+ }
47
+
48
+ /**
49
+ * Note a request for an account that does not exist, from any address, and say
50
+ * whether the site has just reached `threshold` of them within `windowMin`.
51
+ * Mutates `stamps` (an array of times).
52
+ *
53
+ * @returns {{count: number, tripped: boolean}} tripped only on the request that reaches the threshold
54
+ */
55
+ export function recordUnknown(stamps, {now = Date.now(), threshold = RESET_UNKNOWN.threshold, windowMin = RESET_UNKNOWN.windowMin} = {}) {
56
+ const since = now - windowMin * 60_000;
57
+ const keep = stamps.filter(t => t >= since);
58
+ stamps.length = 0;
59
+ stamps.push(...keep, now);
60
+ return {count: stamps.length, tripped: stamps.length === threshold};
61
+ }
62
+
63
+ /** Forget site-wide stamps older than the window. Mutates `stamps`. */
64
+ export function pruneUnknown(stamps, {now = Date.now(), windowMin = RESET_UNKNOWN.windowMin} = {}) {
65
+ const keep = stamps.filter(t => t >= now - windowMin * 60_000);
66
+ stamps.length = 0;
67
+ stamps.push(...keep);
68
+ }
69
+
70
+ /** Forget addresses with nothing inside the window. */
71
+ export function pruneResets(recent, {now = Date.now(), windowMin = RESET_PROBE.windowMin} = {}) {
72
+ const since = now - windowMin * 60_000;
73
+ for (const [ip, list] of recent) {
74
+ const keep = list.filter(r => r.at >= since);
75
+ if (keep.length) recent.set(ip, keep); else recent.delete(ip);
76
+ }
77
+ }
78
+
79
+ /** One log entry as the Sign-ins list shows it. */
80
+ export function resetRowText(e) {
81
+ return {okText: 'Reset asked', tone: 'reset', note: RESET_REASONS[e.reason] || e.reason || (e.ok ? 'Link sent' : 'Not sent')};
82
+ }
@@ -11,7 +11,7 @@ export const DEFAULT_SETTINGS = {
11
11
  probes: {enabled: true, blockAfter: 5, withinMin: 10, blockForMin: 60, extra: [], allow: []},
12
12
  signinDays: 30,
13
13
  staleAdminDays: 90,
14
- notify: {lockouts: true, successAfterFails: true, probeBlocks: false}
14
+ notify: {lockouts: true, successAfterFails: true, probeBlocks: false, resetProbing: true}
15
15
  };
16
16
 
17
17
  const int = (v, min, max) => {
@@ -22,6 +22,7 @@
22
22
  <div class="scx-card scx-tile"><small>Failed sign-ins, 24h <span data-help="Failed sign-ins in the last 24 hours: a wrong password, an unknown email, a locked account."></span></small><strong data-bind-text="ov.value.failed">—</strong></div>
23
23
  <div class="scx-card scx-tile"><small>Blocked now <span data-help="Addresses blocked by a rule or for probing, and accounts or addresses locked after wrong passwords."></span></small><strong data-bind-text="ov.value.blocked">—</strong></div>
24
24
  <div class="scx-card scx-tile"><small>Probes refused today <span data-help="Requests for things no Domma site has - WordPress logins, .env files, database dumps. Scanners send them to every site they find; they are answered with an empty 'not found'."></span></small><strong data-bind-text="ov.value.probes">—</strong></div>
25
+ <div class="scx-card scx-tile"><small>Reset requests, 24h <span data-help="&quot;Forgot your password?&quot; requests in the last 24 hours, sent or not. Sign-ins › Resets lists them, with what happened and where from."></span></small><strong data-bind-text="ov.value.resets">—</strong></div>
25
26
  </div>
26
27
 
27
28
  <div class="scx-card">
@@ -41,6 +42,7 @@
41
42
  <button type="button" data-on-click="res_all" data-bind-class="result.value === 'all' && 'is-on'">All</button>
42
43
  <button type="button" data-on-click="res_failed" data-bind-class="result.value === 'failed' && 'is-on'">Failed</button>
43
44
  <button type="button" data-on-click="res_ok" data-bind-class="result.value === 'ok' && 'is-on'">Succeeded</button>
45
+ <button type="button" data-on-click="res_resets" data-bind-class="result.value === 'resets' && 'is-on'">Resets</button>
44
46
  </div>
45
47
  <select class="form-input scx-days" aria-label="Period" data-model="days.value">
46
48
  <option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="365">All kept</option>
@@ -150,6 +152,7 @@
150
152
  .scx-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
151
153
  .scx-pill { display: inline-flex; align-items: center; justify-self: start; font-size: var(--dm-text-xs); font-weight: 600; padding: .05rem .5rem; border-radius: 999px; }
152
154
  .scx-pill[data-ok="true"] { color: var(--dm-success); background: color-mix(in srgb, var(--dm-success) 14%, transparent); }
155
+ .scx-pill[data-ok="reset"] { color: var(--dm-info); background: color-mix(in srgb, var(--dm-info) 14%, transparent); }
153
156
  .scx-pill[data-ok="false"] { color: var(--dm-danger); background: color-mix(in srgb, var(--dm-danger) 12%, transparent); }
154
157
  .scx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); line-height: 1; color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .25rem; }
155
158
  .scx-kebab:hover { color: var(--dm-primary); }
@@ -10,6 +10,7 @@
10
10
  * only, and rows are addressed by index.
11
11
  * `#/plugins/security/<tab>` opens a tab. Pure logic: ../lib/*.js.
12
12
  */
13
+ import {resetRowText} from '../lib/resets.js';
13
14
  import {checkSettings, DEFAULT_SETTINGS, mergeDefaults} from '../lib/settings.js';
14
15
  import {ipRuleValid} from '../lib/ip.js';
15
16
 
@@ -152,7 +153,8 @@ export const securityView = {
152
153
  const asOf = M.computed(() => (health.value?.at ? `Checked ${new Date(health.value.at).toLocaleTimeString('en-GB', {hour: '2-digit', minute: '2-digit'})}` : ''));
153
154
  const ov = M.computed(() => {
154
155
  const o = overview.value;
155
- return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0)} : {failed: '—', blocked: '—', probes: '—'};
156
+ return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0), resets: String(o.resets24h ?? 0)}
157
+ : {failed: '—', blocked: '—', probes: '—', resets: '—'};
156
158
  });
157
159
  const blockedCount = M.computed(() => { const b = blocks.value; const n = b.rules.length + b.temp.length + b.locks.length; return n ? String(n) : ''; });
158
160
 
@@ -185,18 +187,25 @@ export const securityView = {
185
187
  const q = query.value.trim().toLowerCase();
186
188
  const r = result.value;
187
189
  const only = ipFilter.value;
188
- signinList = signins.value.filter(e => (r === 'all' || (r === 'ok') === Boolean(e.ok))
190
+ // A reset request is in the log too (kind 'reset'): only under All and Resets.
191
+ const isReset = (e) => e.kind === 'reset';
192
+ signinList = signins.value.filter(e => (r === 'all' || (r === 'resets' ? isReset(e) : !isReset(e) && (r === 'ok') === Boolean(e.ok)))
189
193
  && (!only || e.ip === only)
190
194
  && (!q || [e.email, e.ip, e.ua, e.reason].some(v => String(v || '').toLowerCase().includes(q))));
191
- signinRows.value = signinList.slice(0, 1000).map((e, i) => ({
192
- id: `s${i}`, when: when(e.at), ok: String(Boolean(e.ok)), okText: e.ok ? 'Signed in' : 'Failed',
193
- email: e.email || '—', ip: e.ip || '—', ua: e.ua || '',
194
- note: e.ok ? browserOf(e.ua) : `${REASONS[e.reason] || e.reason || 'Failed'} · ${browserOf(e.ua)}`
195
- }));
195
+ signinRows.value = signinList.slice(0, 1000).map((e, i) => {
196
+ const row = {id: `s${i}`, when: when(e.at), email: e.email || '—', ip: e.ip || '—', ua: e.ua || ''};
197
+ if (isReset(e)) {
198
+ const t = resetRowText(e);
199
+ return {...row, ok: t.tone, okText: t.okText, note: `${t.note} · ${browserOf(e.ua)}`};
200
+ }
201
+ return {...row, ok: String(Boolean(e.ok)), okText: e.ok ? 'Signed in' : 'Failed',
202
+ note: e.ok ? browserOf(e.ua) : `${REASONS[e.reason] || e.reason || 'Failed'} · ${browserOf(e.ua)}`};
203
+ });
196
204
  }));
197
205
  const signinText = M.computed(() => {
198
206
  const n = signinList.length + 0 * signinRows.value.length;
199
207
  const f = ipFilter.value ? ` from ${ipFilter.value}` : '';
208
+ if (result.value === 'resets') return `${n.toLocaleString('en-GB')} reset request${n === 1 ? '' : 's'}${f}`;
200
209
  return `${n.toLocaleString('en-GB')} sign-in${n === 1 ? '' : 's'}${f}`;
201
210
  });
202
211
  const signinEmpty = M.computed(() => (query.value.trim() || ipFilter.value ? 'No sign-ins match.' : 'No sign-ins in this period.'));
@@ -280,7 +289,9 @@ export const securityView = {
280
289
  }
281
290
  function exportCsv() {
282
291
  if (!signinList.length) { E.toast('Nothing to export.', {type: 'info'}); return; }
283
- const rows = signinList.map(e => ({time: e.at, result: e.ok ? 'signed in' : 'failed', email: e.email, address: e.ip, reason: e.ok ? '' : (REASONS[e.reason] || e.reason || ''), browser: e.ua}));
292
+ const rows = signinList.map(e => (e.kind === 'reset'
293
+ ? {time: e.at, result: 'reset requested', email: e.email, address: e.ip, reason: resetRowText(e).note, browser: e.ua}
294
+ : {time: e.at, result: e.ok ? 'signed in' : 'failed', email: e.email, address: e.ip, reason: e.ok ? '' : (REASONS[e.reason] || e.reason || ''), browser: e.ua}));
284
295
  const url = URL.createObjectURL(new Blob([toCsv(rows)], {type: 'text/csv;charset=utf-8'}));
285
296
  const a = Object.assign(document.createElement('a'), {href: url, download: `sign-ins-${location.hostname}-${new Date().toISOString().slice(0, 10)}.csv`});
286
297
  document.body.appendChild(a); a.click(); a.remove();
@@ -385,14 +396,14 @@ export const securityView = {
385
396
  probeOn: M.observable(s.probes.enabled), after: M.observable(String(s.probes.blockAfter)), within: M.observable(String(s.probes.withinMin)), forMin: M.observable(String(s.probes.blockForMin)),
386
397
  extra: M.observable(s.probes.extra.join('\n')), allow: M.observable(s.probes.allow.join('\n')),
387
398
  signinDays: M.observable(String(s.signinDays)), staleDays: M.observable(String(s.staleAdminDays)),
388
- nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks)
399
+ nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks), nReset: M.observable(s.notify.resetProbing)
389
400
  };
390
401
  const body = () => ({
391
402
  lockout: {enabled: f.lockOn.value, accountThreshold: f.acc.value, addressThreshold: f.addr.value, windowMin: f.win.value, baseMin: f.base.value, maxMin: f.max.value},
392
403
  policy: {enabled: f.polOn.value, minLength: f.minLen.value, blockCommon: f.common.value, blockPersonal: f.personal.value},
393
404
  probes: {enabled: f.probeOn.value, blockAfter: f.after.value, withinMin: f.within.value, blockForMin: f.forMin.value, extra: f.extra.value, allow: f.allow.value},
394
405
  signinDays: f.signinDays.value, staleAdminDays: f.staleDays.value,
395
- notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value}
406
+ notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value, resetProbing: f.nReset.value}
396
407
  });
397
408
  const opened = JSON.stringify(body());
398
409
  const saving = M.observable(false);
@@ -436,6 +447,7 @@ export const securityView = {
436
447
  ${sw('nLock', 'Notify when an account or address is locked')}
437
448
  ${sw('nAfter', 'Notify when someone signs in after many wrong passwords')}
438
449
  ${sw('nProbe', 'Notify when a scanner is blocked', 'Off by default: on a public site this happens daily.')}
450
+ ${sw('nReset', 'Notify when password resets look like someone trying addresses', 'One address asking about 3 different accounts within an hour (a person only asks about their own), or 10 requests in an hour for accounts that do not exist, from anywhere. Every request is in the sign-in log either way.')}
439
451
  </div></section>
440
452
  <section><h4>Locked out yourself?</h4>
441
453
  <p class="scx-note">Run this in the site's folder on the server - it clears lockouts and blocks, and the site notices within seconds:</p>
@@ -499,6 +511,7 @@ export const securityView = {
499
511
  const model = {tab, loading, error, checksOk, canManage, scoreText, scoreWidth, scoreTone, attentionText, asOf, ov, blockedCount, checks,
500
512
  result, days, query, signinRows, signinText, signinEmpty, lockRows, tempRows, ruleRows, newRule, newNote, youText};
501
513
  const methods = {
514
+ res_resets: () => { result.value = 'resets'; },
502
515
  tab_overview: () => setTab('overview'), tab_signins: () => setTab('signins'), tab_blocked: () => setTab('blocked'),
503
516
  res_all: () => { result.value = 'all'; }, res_failed: () => { result.value = 'failed'; }, res_ok: () => { result.value = 'ok'; },
504
517
  exportCsv,
@@ -31,6 +31,7 @@ import {checkSettings, mergeDefaults} from './admin/lib/settings.js';
31
31
  import {accountKey, activeLocks, addressKey, lockedUntil, pruneLockouts, recordFailure, recordSuccess, retryText} from './admin/lib/lockout.js';
32
32
  import {passwordProblems} from './admin/lib/policy.js';
33
33
  import {isProbe} from './admin/lib/probes.js';
34
+ import {pruneResets, pruneUnknown, recordReset, recordUnknown} from './admin/lib/resets.js';
34
35
  import {blockedBy, isTrusted, pruneBlocks, recordProbe} from './admin/lib/blocks.js';
35
36
  import {ipRuleValid, isLoopbackIp, plainIp} from './admin/lib/ip.js';
36
37
  import {getConfig, saveConfig} from '../../server/config.js';
@@ -72,6 +73,7 @@ async function security(fastify, options = {}) {
72
73
  const notify = (p) => { try { hooks.notify?.(p); } catch { /* notifications are a courtesy */ } };
73
74
  hooks.registerNotificationSource?.({id: 'lockouts', label: 'Sign-in lockouts', description: 'An account or address was locked after repeated wrong passwords.', severity: 'warning', audience: 'admins'});
74
75
  hooks.registerNotificationSource?.({id: 'suspicious', label: 'Suspicious sign-ins', description: 'Someone signed in after many wrong passwords.', severity: 'warning', audience: 'admins'});
76
+ hooks.registerNotificationSource?.({id: 'resets', label: 'Password reset probing', description: 'One address asked for password resets of many different accounts.', severity: 'warning', audience: 'admins'});
75
77
  hooks.registerNotificationSource?.({id: 'probes', label: 'Scanners blocked', description: 'An address probing for weaknesses was blocked.', severity: 'info', audience: 'admins', defaultOn: false});
76
78
 
77
79
  // ---------------------------------------------------------------------
@@ -161,6 +163,38 @@ async function security(fastify, options = {}) {
161
163
  }
162
164
  });
163
165
 
166
+ // Password reset requests (core 0.88's auth:resetRequested): into the
167
+ // sign-in log, and a notice when one address asks about many accounts.
168
+ const recentResets = new Map();
169
+ const unknownResets = [];
170
+ hooks.on?.('auth:resetRequested', (e) => {
171
+ try {
172
+ const ip = plainIp(e.ip);
173
+ state.logSignin({at: new Date().toISOString(), kind: 'reset', ok: Boolean(e.sent), email: e.email || '', userId: e.userId || null,
174
+ ip, ua: e.ua || '', reason: e.reason || ''});
175
+ if (isLoopbackIp(ip)) return;
176
+ if (e.reason === 'unknown') {
177
+ const many = recordUnknown(unknownResets);
178
+ if (many.tripped && cfg.notify.resetProbing !== false) {
179
+ notify({source: 'resets', severity: 'warning', title: `${many.count} password resets for accounts that do not exist`,
180
+ dedupeKey: 'reset-unknown',
181
+ body: `Within the hour, ${many.count} "Forgot your password?" requests named accounts this site does not have, from ${new Set(state.readSignins(Date.now() - 3_600_000).filter(x => x.kind === 'reset' && x.reason === 'unknown').map(x => x.ip)).size || 'several'} addresses - someone trying email addresses. The sign-in log (Resets) shows which.`,
182
+ link: '#/plugins/security/signins'});
183
+ }
184
+ }
185
+ if (!ip) return;
186
+ const {accounts, tripped} = recordReset(recentResets, ip, e.email);
187
+ if (tripped && cfg.notify.resetProbing !== false) {
188
+ notify({source: 'resets', severity: 'warning', title: `Password resets asked for ${accounts} accounts from ${ip}`,
189
+ dedupeKey: `reset-probe:${ip}`,
190
+ body: `${ip} asked for password resets of ${accounts} different accounts within the hour - a person only ever asks about their own, so this is someone trying email addresses. The log shows which; block the address if it is not someone you know.`,
191
+ link: '#/plugins/security/signins'});
192
+ }
193
+ } catch (err) {
194
+ log.warn(`[security] reset request not recorded: ${err.message}`);
195
+ }
196
+ });
197
+
164
198
  hooks.on?.('auth:loginSucceeded', (e) => {
165
199
  try {
166
200
  const ip = plainIp(e.ip);
@@ -217,12 +251,14 @@ async function security(fastify, options = {}) {
217
251
  routes.get('/overview', may('read'), async () => {
218
252
  const s = state.get();
219
253
  const now = Date.now();
220
- const day = state.readSignins(now - 86_400_000);
254
+ const all = state.readSignins(now - 86_400_000);
255
+ const day = all.filter(e => e.kind !== 'reset');
221
256
  const today = s.counters.day === new Date().toISOString().slice(0, 10) ? s.counters : {probes: 0, blocks: 0, failed: 0};
222
257
  return {
223
258
  today,
224
259
  failed24h: day.filter(e => !e.ok).length,
225
260
  succeeded24h: day.filter(e => e.ok).length,
261
+ resets24h: all.length - day.length,
226
262
  locks: activeLocks(s.lockouts, now).length,
227
263
  blocked: Object.values(s.temp).filter(t => t.until > now).length + s.rules.length,
228
264
  checks: canCheck,
@@ -302,6 +338,8 @@ async function security(fastify, options = {}) {
302
338
  // ---------------------------------------------------------------------
303
339
  const tidy = () => {
304
340
  state.change((s) => { pruneLockouts(s.lockouts, {windowMin: cfg.lockout.windowMin}); pruneBlocks(s); });
341
+ pruneResets(recentResets);
342
+ pruneUnknown(unknownResets);
305
343
  state.pruneSignins(cfg.signinDays);
306
344
  };
307
345
  const daily = setInterval(tidy, 6 * 3_600_000);
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "security",
3
3
  "displayName": "Security",
4
- "version": "1.0.3",
4
+ "version": "1.1.0",
5
5
  "tier": "free",
6
6
  "description": "Keeps a Domma CMS site safe: a health check of what is weak and how to fix it, sign-in lockout after repeated wrong passwords, password rules, a log of every sign-in, and blocking of the scanners that probe every site for WordPress, .env files and the like.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-09-26",
8
+ "date": "2026-09-27",
9
9
  "icon": "shield",
10
10
  "permissions": [
11
11
  {
@@ -132,3 +132,37 @@ test('health: facts become checks; unknown is info, never a pass; the score coun
132
132
  assert.equal(unknown.checks.find(c => c.id === 'certificate').status, 'info');
133
133
  assert.equal(unknown.checks.find(c => c.id === 'headers').status, 'info');
134
134
  });
135
+
136
+ test('resets: one address asking about 3 different accounts in an hour trips once', async () => {
137
+ const {recordReset, pruneResets, resetRowText} = await import('../admin/lib/resets.js');
138
+ const recent = new Map();
139
+ const now = Date.now();
140
+ const at = (m) => ({now: now + m * MIN});
141
+ assert.deepEqual(recordReset(recent, '1.2.3.4', 'a@x.test', at(0)), {accounts: 1, tripped: false});
142
+ // The same account again is not another account.
143
+ assert.equal(recordReset(recent, '1.2.3.4', 'A@x.test', at(1)).accounts, 1);
144
+ assert.equal(recordReset(recent, '1.2.3.4', 'b@x.test', at(2)).tripped, false);
145
+ assert.deepEqual(recordReset(recent, '1.2.3.4', 'c@x.test', at(3)), {accounts: 3, tripped: true});
146
+ assert.equal(recordReset(recent, '1.2.3.4', 'd@x.test', at(4)).tripped, false, 'only on crossing');
147
+ // Another address is counted on its own; an hour later the first starts again.
148
+ assert.equal(recordReset(recent, '5.6.7.8', 'a@x.test', at(5)).accounts, 1);
149
+ assert.equal(recordReset(recent, '1.2.3.4', 'z@x.test', at(70)).accounts, 1);
150
+ pruneResets(recent, {now: now + 200 * MIN});
151
+ assert.equal(recent.size, 0);
152
+ assert.equal(recordReset(recent, '', 'a@x.test').tripped, false);
153
+ assert.equal(resetRowText({reason: 'no-smtp'}).note, 'Not sent - no mail server');
154
+ assert.equal(resetRowText({reason: 'odd'}).note, 'odd');
155
+ });
156
+
157
+ test('resets: 10 requests for unknown accounts in an hour, from anywhere, trip once', async () => {
158
+ const {recordUnknown, pruneUnknown} = await import('../admin/lib/resets.js');
159
+ const stamps = [];
160
+ const now = Date.now();
161
+ for (let i = 0; i < 9; i++) assert.equal(recordUnknown(stamps, {now: now + i * MIN}).tripped, false);
162
+ assert.deepEqual(recordUnknown(stamps, {now: now + 9 * MIN}), {count: 10, tripped: true});
163
+ assert.equal(recordUnknown(stamps, {now: now + 10 * MIN}).tripped, false, 'only on reaching it');
164
+ // An hour on, the early ones have gone.
165
+ assert.equal(recordUnknown(stamps, {now: now + 65 * MIN}).count, 7, 'minutes 5-10 still inside the hour, and this one');
166
+ pruneUnknown(stamps, {now: now + 200 * MIN});
167
+ assert.equal(stamps.length, 0);
168
+ });
@@ -7,13 +7,17 @@
7
7
  * POST /api/auth/me/avatar - upload your picture (multipart `file`); DELETE removes it
8
8
  * GET /api/auth/avatar/:name - an avatar image (public: an <img> cannot send the token)
9
9
  * POST /api/auth/login/verify - { ticket, response } → tokens, when a sign-in check asked a question (2FA)
10
+ * POST /api/auth/forgot-password - { email } → { ok, expiresIn } - emails a reset link (services/passwordReset.js)
11
+ * POST /api/auth/reset-password/check - { token, password? } → { email, expiresAt, minutesLeft, problems }
12
+ * POST /api/auth/reset-password - { token, password } → { ok } - signs the account out everywhere
10
13
  * POST /api/auth/refresh - { refreshToken } → { token } - only while its session is live
11
14
  * POST /api/auth/logout - { refreshToken } → { ok: true } - ends the session
12
15
  * GET /api/auth/sessions - your sessions; DELETE /api/auth/sessions/:sid; POST /api/auth/sessions/revoke-others
13
16
  *
14
17
  * Sign-in checks and password checks come from plugins (services/authChecks.js);
15
18
  * sessions live in services/sessions.js. Events: auth:loginFailed, auth:loginSucceeded,
16
- * auth:loggedOut, auth:passwordChanged, auth:rateLimited (and user:loggedIn, as before).
19
+ * auth:loggedOut, auth:passwordChanged, auth:rateLimited, auth:resetRequested (and user:loggedIn, as before).
20
+ * Password reset (the link, where it may point, the email) is services/passwordReset.js.
17
21
  */
18
22
  import crypto from 'node:crypto';
19
23
  import {config, getConfig} from '../../config.js';
@@ -30,8 +34,6 @@ import {
30
34
  createUser,
31
35
  getUserByEmail,
32
36
  getUserById,
33
- getUserByResetToken,
34
- setResetToken,
35
37
  touchLastLogin,
36
38
  updateUser,
37
39
  validatePassword
@@ -41,7 +43,8 @@ import {acceptedType, AVATAR_DIR, AVATAR_FILE, AVATAR_MAX_BYTES, removeAvatar, s
41
43
  import fsp from 'node:fs/promises';
42
44
  import nodePath from 'node:path';
43
45
  import {buildClearedSessionCookie, buildSessionCookie} from '../../services/viewerSession.js';
44
- import {createTransport, sendEmail} from '../../services/email.js';
46
+ import {checkResetToken, originOf, recordOrigin, requestReset, resetLifetime} from '../../services/passwordReset.js';
47
+ import {isSmtpConfigured} from '../../services/email.js';
45
48
  import {loginFailed, loginRateLimited} from '../../services/notification-sources.js';
46
49
  import {afterIndexOf, checkPassword, runLoginChecks, verifyChallenge} from '../../services/authChecks.js';
47
50
  import {createSession, isLegacyTokenRevoked, listSessions, revokeLegacyToken, revokeSession, revokeUserSessions, useSession} from '../../services/sessions.js';
@@ -67,9 +70,12 @@ const passwordProblems = (problems) => problems.join(' ');
67
70
 
68
71
  export async function authRoutes(fastify) {
69
72
  // GET /api/auth/setup-status
73
+ // `resetByEmail`: whether "Forgot your password?" can work here at all (a mail server is set up).
70
74
  fastify.get('/auth/setup-status', async () => {
71
75
  const count = await countUsers();
72
- return { needsSetup: count === 0 };
76
+ const site = getConfig('site') || {};
77
+ return {needsSetup: count === 0, siteTitle: String(site.title || ''), resetByEmail: isSmtpConfigured(site.smtp),
78
+ resetExpiresIn: resetLifetime().text};
73
79
  });
74
80
 
75
81
  // POST /api/auth/setup - create the very first admin (only allowed when no users exist)
@@ -100,6 +106,7 @@ export async function authRoutes(fastify) {
100
106
  try {
101
107
  const user = await createUser({name, email, password, role: 'super-admin'});
102
108
  const sid = await createSession({userId: user.id, ip: request.ip, ua: uaOf(request), ttlMs: parseDuration(refreshTokenExpiry)});
109
+ await recordOrigin(request);
103
110
  const {token, refreshToken} = signTokens(fastify, user, sid);
104
111
  return reply.status(201).send({token, refreshToken, user});
105
112
  } finally {
@@ -179,6 +186,7 @@ export async function authRoutes(fastify) {
179
186
  const ua = uaOf(request);
180
187
  await touchLastLogin(user.id);
181
188
  const sid = await createSession({userId: user.id, ip, ua, ttlMs: parseDuration(refreshTokenExpiry)});
189
+ await recordOrigin(request);
182
190
  const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
183
191
  hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
184
192
  hooks.emit('auth:loginSucceeded', {userId: user.id, email: user.email, role: user.role, ip, ua, sid});
@@ -386,86 +394,55 @@ export async function authRoutes(fastify) {
386
394
  .send(data);
387
395
  });
388
396
 
389
- // POST /api/auth/forgot-password - send a password reset email (no auth)
397
+ // POST /api/auth/forgot-password - email a reset link (no auth)
398
+ // Answers at once and the same way for every address: the lookup and the send happen
399
+ // after the reply, so neither the answer nor how long it took says whether an account exists.
390
400
  fastify.post('/auth/forgot-password', { config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, async (request) => {
391
401
  const {email} = request.body || {};
392
- if (!email) return {ok: true}; // always ok - prevents enumeration
393
-
394
- try {
395
- const user = await getUserByEmail(email);
396
- if (!user || !user.isActive) return {ok: true};
397
-
398
- const token = crypto.randomBytes(32).toString('hex');
399
- const hash = crypto.createHash('sha256').update(token).digest('hex');
400
-
401
- const authCfg = getConfig('auth');
402
- const expiryMs = parseDuration(authCfg.resetTokenExpiry || '1h');
403
- const expiry = new Date(Date.now() + expiryMs).toISOString();
404
-
405
- await setResetToken(user.id, hash, expiry);
406
-
407
- const siteConfig = getConfig('site');
408
- const smtp = siteConfig.smtp || {};
409
- const sitePort = config.server.port || 4096;
410
- const resetUrl = `${request.protocol}://${request.hostname}:${sitePort}/admin/#/reset-password?token=${token}`;
411
-
412
- const transport = await createTransport(smtp);
413
- const from = smtp.fromAddress || 'noreply@example.com';
414
- const fromName = smtp.fromName || siteConfig.title || 'Domma CMS';
415
-
416
- await sendEmail(transport, {
417
- from,
418
- fromName,
419
- to: user.email,
420
- subject: 'Reset your password',
421
- html: `
422
- <!DOCTYPE html>
423
- <html>
424
- <body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:24px;">
425
- <h2 style="color:#333;">Password Reset</h2>
426
- <p>Hi ${user.name},</p>
427
- <p>We received a request to reset your password. Click the link below - it expires in 1 hour.</p>
428
- <p style="margin:24px 0;">
429
- <a href="${resetUrl}" style="background:#5b8cff;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">Reset Password</a>
430
- </p>
431
- <p style="color:#888;font-size:.85rem;">If you didn't request this, you can safely ignore this email.</p>
432
- <p style="color:#bbb;font-size:.8rem;">${resetUrl}</p>
433
- </body>
434
- </html>`.trim(),
435
- text: `Hi ${user.name},\n\nReset your password here:\n${resetUrl}\n\nThis link expires in 1 hour.\n\nIf you didn't request this, ignore this email.`
436
- });
437
- } catch (err) {
438
- console.error('[auth] forgot-password error:', err.message);
402
+ if (typeof email === 'string' && email.trim() && email.length <= 320) {
403
+ const job = {email, origin: originOf(request), ip: request.ip, ua: uaOf(request), log: fastify.log};
404
+ setImmediate(() => { requestReset(job).catch(() => {}); });
439
405
  }
406
+ return {ok: true, expiresIn: resetLifetime().text};
407
+ });
440
408
 
441
- return {ok: true};
409
+ // POST /api/auth/reset-password/check - {token, password?}: is the link still good (the screen asks on
410
+ // opening, so an expired link says so before anything is typed), and - with a password - what the
411
+ // site's password rules make of it, as it is typed. Only ever for the holder of a live link.
412
+ fastify.post('/auth/reset-password/check', {config: {rateLimit: {max: 60, timeWindow: '1 minute'}}}, async (request, reply) => {
413
+ const {token, password} = request.body || {};
414
+ const user = await checkResetToken(token);
415
+ if (!user) return reply.status(400).send({error: 'Invalid or expired reset link', expired: true});
416
+ let problems = [];
417
+ if (typeof password === 'string' && password) {
418
+ problems = password.length < 8 ? ['Password must be at least 8 characters.']
419
+ : await checkPassword(password, {email: user.email, name: user.name, userId: user.id, request}, {log: fastify.log});
420
+ }
421
+ const ms = Date.parse(user.resetTokenExpiry) - Date.now();
422
+ return {email: user.email, expiresAt: user.resetTokenExpiry, minutesLeft: Math.max(1, Math.ceil(ms / 60_000)), problems};
442
423
  });
443
424
 
444
425
  // POST /api/auth/reset-password - consume token and set new password (no auth)
445
426
  fastify.post('/auth/reset-password', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (request, reply) => {
446
427
  const {token, password} = request.body || {};
447
- if (!token || !password) {
428
+ if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
448
429
  return reply.status(400).send({error: 'token and password are required'});
449
430
  }
450
431
  if (password.length < 8) {
451
432
  return reply.status(400).send({error: 'Password must be at least 8 characters'});
452
433
  }
453
434
 
454
- const hash = crypto.createHash('sha256').update(token).digest('hex');
455
- const user = await getUserByResetToken(hash);
435
+ const user = await checkResetToken(token);
456
436
  if (!user) {
457
- return reply.status(400).send({error: 'Invalid or expired reset link'});
458
- }
459
- if (new Date(user.resetTokenExpiry) < new Date()) {
460
- await clearResetToken(user.id);
461
- return reply.status(400).send({error: 'Invalid or expired reset link'});
437
+ return reply.status(400).send({error: 'Invalid or expired reset link', expired: true});
462
438
  }
463
439
 
464
440
  const problems = await checkPassword(password, {email: user.email, name: user.name, userId: user.id, request}, {log: fastify.log});
465
441
  if (problems.length) return reply.status(400).send({error: passwordProblems(problems), problems});
466
442
 
467
- await updateUser(user.id, {password});
443
+ // Used up BEFORE the password is set: two submissions at once must not both get through.
468
444
  await clearResetToken(user.id);
445
+ await updateUser(user.id, {password});
469
446
  await revokeUserSessions(user.id);
470
447
  hooks.emit('auth:passwordChanged', {userId: user.id, by: 'reset', ip: request.ip, ua: uaOf(request)});
471
448
  return {ok: true};
@@ -520,6 +497,7 @@ export async function authRoutes(fastify) {
520
497
  return reply.status(401).send({ error: 'User not found or inactive' });
521
498
  }
522
499
 
500
+ await recordOrigin(request);
523
501
  const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
524
502
  const token = fastify.jwt.sign({ ...safeUser, type: 'access', ...(payload.sid && {sid: payload.sid}) }, { expiresIn: accessTokenExpiry });
525
503
  setSessionCookie(request, reply, token);
@@ -19,6 +19,7 @@
19
19
  * POST /forms/submit/:slug - public: accept submission
20
20
  * POST /forms/test-email - admin: send test email
21
21
  */
22
+ import {keepSubmissionsOut} from '../../services/siteGitignore.js';
22
23
  import {createRequire} from 'module';
23
24
  import {fileURLToPath} from 'url';
24
25
  import path from 'path';
@@ -893,6 +894,8 @@ export async function formsRoutes(fastify) {
893
894
  excused,
894
895
  outcome: ended ? {type: 'ended', trigger: ended.key, field: ended.field} : null
895
896
  });
897
+ // What a visitor sent is theirs, not the site's content: keep it out of the site repo.
898
+ try { keepSubmissionsOut(ROOT, targetSlug); } catch { /* never fail a submission over this */ }
896
899
  } else {
897
900
  // Could not resolve or provision a target - e.g. collection
898
901
  // storage is explicitly disabled with no slug. Fail loudly
@@ -23,6 +23,8 @@ import {
23
23
  savePluginState
24
24
  } from '../../services/plugins.js';
25
25
  import {getEffectiveLevel} from '../../services/userRoles.js';
26
+ import {getToolStates, planSwitch, requiresOf, switchableUnits, usesOf} from '../../services/tools.js';
27
+ import {applyCascade} from './tools.js';
26
28
  import {scaffoldPlugin} from '../../services/pluginScaffold.js';
27
29
  import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from '../../services/pluginFiles.js';
28
30
  import {getConfig} from '../../config.js';
@@ -83,14 +85,14 @@ let _restartTimer = null;
83
85
  * @param {number} [delayMs]
84
86
  * @returns {void}
85
87
  */
86
- function scheduleRestart(exit, delayMs = 1500) {
88
+ export function scheduleRestart(exit, delayMs = 1500) {
87
89
  if (_restartTimer) clearTimeout(_restartTimer);
88
90
  _restartTimer = setTimeout(exit, delayMs);
89
91
  _restartTimer.unref?.();
90
92
  }
91
93
 
92
94
  /** Admins can opt out of automatic restarts in config/server.json. */
93
- function autoRestartEnabled() {
95
+ export function autoRestartEnabled() {
94
96
  try {
95
97
  return getConfig('server')?.restartOnPluginToggle !== false;
96
98
  } catch {
@@ -251,6 +253,13 @@ export async function pluginsRoutes(fastify, opts = {}) {
251
253
  // Enabled but displaced by another plugin that replaces it. Shown
252
254
  // so the screen can explain the inactivity instead of looking broken.
253
255
  supersededBy: loadedPlugins[manifest.name]?.supersededBy ?? null,
256
+ // Enabled but not running because something it requires is not
257
+ // (a built-in Tool switched off, a required plugin that is off).
258
+ blockedBy: loadedPlugins[manifest.name]?.blockedBy ?? null,
259
+ // The site's manager holds this switch: the admin here cannot move it.
260
+ locked: states[manifest.name]?.locked === true,
261
+ requires: requiresOf(manifest),
262
+ uses: usesOf(manifest),
254
263
  settings: maskSettings(states[manifest.name]?.settings || {}, '', schemaSecrets(manifest)),
255
264
  // What Configure edits: an optional plugin.json `settingsSchema`
256
265
  // ([{key, label, type: text|number|select|boolean, options, min,
@@ -311,6 +320,27 @@ export async function pluginsRoutes(fastify, opts = {}) {
311
320
  }
312
321
 
313
322
  const prevState = getPluginStates()[name] || {};
323
+
324
+ // The switch goes through the same planner the Manager uses: a lock
325
+ // refuses it, and anything it drags along (a plugin that requires this
326
+ // one going off, a requirement coming on) is answered as a plan to
327
+ // confirm - `cascade: true` on the repeat applies it.
328
+ let cascaded = [];
329
+ if (enabled !== undefined && !!enabled !== !!prevState.enabled) {
330
+ const plan = planSwitch(switchableUnits({manifests, pluginStates: getPluginStates(), toolStates: getToolStates()}),
331
+ {[name]: !!enabled});
332
+ if (!plan.ok) {
333
+ const r = plan.refused[0];
334
+ const who = r.name === name ? (manifest.displayName || name) : r.name;
335
+ return reply.status(r.reason.includes('locked') ? 423 : 409).send({error: `${who} ${r.reason}.`, plan});
336
+ }
337
+ cascaded = plan.cascaded;
338
+ if ((cascaded.length || plan.losses.length) && request.body?.cascade !== true) {
339
+ return reply.status(409).send({error: 'This switch changes other Tools too.', needsConfirm: true, plan});
340
+ }
341
+ await applyCascade(cascaded, fastify);
342
+ }
343
+
314
344
  const update = {};
315
345
  if (enabled !== undefined) update.enabled = !!enabled;
316
346
  // A secret sent back as the mask keeps its stored value (see maskSettings).
@@ -329,7 +359,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
329
359
  // but only where a supervisor will bring the process back, and only
330
360
  // when the enabled flag actually changed (a settings-only save must not
331
361
  // bounce the site).
332
- const toggled = enabled !== undefined && !!enabled !== !!prevState.enabled;
362
+ const toggled = (enabled !== undefined && !!enabled !== !!prevState.enabled) || cascaded.length > 0;
333
363
  const supervised = hasSupervisor();
334
364
  const restarting = toggled && supervised && autoRestartEnabled();
335
365
  if (restarting) scheduleRestart(exit);