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.
Files changed (36) hide show
  1. package/admin/css/admin.css +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/templates/login.html +16 -0
  4. package/admin/js/views/login.js +2 -2
  5. package/bin/update.js +7 -3
  6. package/config/plugins.json +5 -0
  7. package/package.json +2 -1
  8. package/plugins/free-tier.lock.json +25 -1
  9. package/plugins/security/CLAUDE.md +41 -0
  10. package/plugins/security/admin/lib/blocks.js +46 -0
  11. package/plugins/security/admin/lib/health.js +148 -0
  12. package/plugins/security/admin/lib/ip.js +44 -0
  13. package/plugins/security/admin/lib/lockout.js +78 -0
  14. package/plugins/security/admin/lib/policy.js +32 -0
  15. package/plugins/security/admin/lib/probes.js +39 -0
  16. package/plugins/security/admin/lib/settings.js +86 -0
  17. package/plugins/security/admin/templates/security.html +170 -0
  18. package/plugins/security/admin/views/security.js +529 -0
  19. package/plugins/security/bin/unlock.js +81 -0
  20. package/plugins/security/config.js +7 -0
  21. package/plugins/security/plugin.js +303 -0
  22. package/plugins/security/plugin.json +46 -0
  23. package/plugins/security/server/common-passwords.NOTICE +3 -0
  24. package/plugins/security/server/common-passwords.txt +10001 -0
  25. package/plugins/security/server/facts.js +133 -0
  26. package/plugins/security/server/state.js +106 -0
  27. package/plugins/security/tests/security.test.js +134 -0
  28. package/server/routes/api/auth.js +180 -39
  29. package/server/routes/api/users.js +20 -0
  30. package/server/server.js +25 -7
  31. package/server/services/audit.js +63 -0
  32. package/server/services/authChecks.js +128 -0
  33. package/server/services/network.js +49 -0
  34. package/server/services/plugins.js +5 -1
  35. package/server/services/sessions.js +171 -0
  36. package/server/services/siteGitignore.js +1 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Network trust - who a request is really from.
3
+ *
4
+ * A Domma CMS sits behind proxies: Apache in front of the manager, and the
5
+ * manager in front of each site. Two things follow, and both went wrong:
6
+ *
7
+ * 1. TRUST_PROXY used to be a yes/no. "Yes" trusts EVERY hop, and Fastify then
8
+ * takes the LEFT-most X-Forwarded-For entry - whatever the visitor chose to
9
+ * send. `loopback` trusts only local proxies and takes the right-most entry
10
+ * they did not add, which is the address Apache saw. So TRUST_PROXY now also
11
+ * takes Fastify's own values: `loopback`, a list of addresses/CIDRs, or a hop
12
+ * count. `1` / `true` keep their old meaning.
13
+ *
14
+ * 2. Loopback was exempt from rate limiting ("local admin use"). Behind a proxy
15
+ * that trusts nothing - dcm, until 2026-09 - EVERY visitor is loopback, so
16
+ * nothing was ever limited, the login included. Only a GENUINELY local call
17
+ * (loopback socket, no forwarding header) is exempt now.
18
+ */
19
+
20
+ const LOOPBACK = /^(127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/;
21
+
22
+ /**
23
+ * Fastify's `trustProxy` from the TRUST_PROXY environment value.
24
+ *
25
+ * @param {string|undefined} value
26
+ * @returns {boolean|string|number}
27
+ */
28
+ export function parseTrustProxy(value) {
29
+ const v = String(value ?? '').trim();
30
+ if (!v || /^(0|false|no|off)$/i.test(v)) return false;
31
+ if (/^(1|true|yes|on)$/i.test(v)) return true;
32
+ if (/^\d+$/.test(v)) return Number(v); // a hop count
33
+ return v; // 'loopback', 'uniquelocal', or addresses/CIDRs, comma-separated
34
+ }
35
+
36
+ /** Is this address a loopback one? */
37
+ export const isLoopback = (ip) => LOOPBACK.test(String(ip ?? ''));
38
+
39
+ /**
40
+ * A call from this machine itself - a health check, a cron job, the manager
41
+ * asking one of its sites - and NOT a visitor relayed by a local proxy.
42
+ *
43
+ * @param {{socket?: {remoteAddress?: string}, raw?: {socket?: {remoteAddress?: string}}, headers?: object}} request
44
+ */
45
+ export function isGenuinelyLocal(request) {
46
+ const peer = request?.raw?.socket?.remoteAddress ?? request?.socket?.remoteAddress ?? '';
47
+ const forwarded = request?.headers?.['x-forwarded-for'];
48
+ return isLoopback(peer) && !forwarded;
49
+ }
@@ -35,6 +35,7 @@ import {createCollection, getCollection} from './collections.js';
35
35
  import * as defaultRolesService from './roles.js';
36
36
  import {normaliseAdminScope} from './roles.js';
37
37
  import * as defaultUsersService from './users.js';
38
+ import {registerLoginCheck, registerPasswordCheck} from './authChecks.js';
38
39
 
39
40
  const PLUGINS_DIR = path.resolve('plugins');
40
41
 
@@ -486,7 +487,10 @@ export async function registerPlugins(fastify) {
486
487
  on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name),
487
488
  // Notifications (0.80) - notify, resolveNotification, registerNotificationSource,
488
489
  // stamped with the plugin's name (its source ids are `<plugin>:<id>`).
489
- ...notifierFor(manifest.name, manifest.displayName || manifest.name)},
490
+ ...notifierFor(manifest.name, manifest.displayName || manifest.name),
491
+ // Sign-in and password checks (0.83) - Security / Security Pro. services/authChecks.js.
492
+ registerLoginCheck: (def) => registerLoginCheck(manifest.name, def),
493
+ registerPasswordCheck: (def) => registerPasswordCheck(manifest.name, def)},
490
494
  settings,
491
495
  config: {}
492
496
  });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Sessions - one per sign-in, behind each refresh token.
3
+ *
4
+ * A refresh token carries `sid`, the id of its session here. Signing out,
5
+ * "sign out everywhere", a password change or reset revoke sessions in this
6
+ * store, which is a file - so a signed-out session STAYS signed out across a
7
+ * restart. (Until 0.83 revoked refresh tokens were an in-memory Set: a restart
8
+ * brought every one of them back for the rest of their 7 days.)
9
+ *
10
+ * Each session records who, when it began and was last used, and from where
11
+ * (address, browser) - Security Pro's Sessions screen shows them. Only ids are
12
+ * stored, never a token.
13
+ *
14
+ * Refresh tokens issued before 0.83 have no `sid`. They stay valid until they
15
+ * expire, unless signed out: then their hash is kept here until their expiry.
16
+ *
17
+ * content/sessions/ is runtime state - never committed (siteGitignore.js).
18
+ */
19
+ import crypto from 'node:crypto';
20
+ import fs from 'node:fs/promises';
21
+ import path from 'node:path';
22
+ import {config} from '../config.js';
23
+
24
+ export const SESSIONS_FILE = path.resolve(path.dirname(config.content.usersDir), 'sessions', 'sessions.json');
25
+ const MAX_PER_USER = 50;
26
+
27
+ let cache = null;
28
+ let queue = Promise.resolve();
29
+
30
+ async function load() {
31
+ if (cache) return cache;
32
+ try {
33
+ const raw = JSON.parse(await fs.readFile(SESSIONS_FILE, 'utf8'));
34
+ cache = {sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {}, legacy: raw.legacy && typeof raw.legacy === 'object' ? raw.legacy : {}};
35
+ } catch {
36
+ cache = {sessions: {}, legacy: {}};
37
+ }
38
+ return cache;
39
+ }
40
+
41
+ /** Expired sessions and legacy revocations go; the oldest go past MAX_PER_USER. */
42
+ function prune(store, now = Date.now()) {
43
+ for (const [sid, s] of Object.entries(store.sessions)) {
44
+ if (Date.parse(s.expiresAt) < now || (s.revokedAt && Date.parse(s.revokedAt) < now - 7 * 86400000)) delete store.sessions[sid];
45
+ }
46
+ for (const [h, exp] of Object.entries(store.legacy)) if (exp < now) delete store.legacy[h];
47
+ const byUser = {};
48
+ for (const [sid, s] of Object.entries(store.sessions)) (byUser[s.userId] ??= []).push([sid, s]);
49
+ for (const list of Object.values(byUser)) {
50
+ if (list.length <= MAX_PER_USER) continue;
51
+ list.sort((a, b) => Date.parse(a[1].lastUsedAt) - Date.parse(b[1].lastUsedAt));
52
+ for (const [sid] of list.slice(0, list.length - MAX_PER_USER)) delete store.sessions[sid];
53
+ }
54
+ }
55
+
56
+ /** Serialise writes: two sign-ins at once must not lose one. */
57
+ function change(fn) {
58
+ const run = queue.then(async () => {
59
+ const store = await load();
60
+ const out = await fn(store);
61
+ prune(store);
62
+ await fs.mkdir(path.dirname(SESSIONS_FILE), {recursive: true});
63
+ const tmp = `${SESSIONS_FILE}.${process.pid}.tmp`;
64
+ await fs.writeFile(tmp, JSON.stringify(store), {mode: 0o600});
65
+ await fs.rename(tmp, SESSIONS_FILE);
66
+ return out;
67
+ });
68
+ queue = run.catch(() => {});
69
+ return run;
70
+ }
71
+
72
+ const clip = (s, n) => String(s ?? '').slice(0, n);
73
+
74
+ /**
75
+ * Begin a session.
76
+ *
77
+ * @param {{userId: string, ip?: string, ua?: string, ttlMs: number}} opts
78
+ * @returns {Promise<string>} the session id, for the refresh token's `sid`
79
+ */
80
+ export function createSession({userId, ip = '', ua = '', ttlMs}) {
81
+ const sid = crypto.randomBytes(16).toString('hex');
82
+ const now = new Date();
83
+ return change((store) => {
84
+ store.sessions[sid] = {
85
+ userId: String(userId),
86
+ createdAt: now.toISOString(),
87
+ lastUsedAt: now.toISOString(),
88
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
89
+ ip: clip(ip, 64), ua: clip(ua, 300)
90
+ };
91
+ return sid;
92
+ });
93
+ }
94
+
95
+ /**
96
+ * A refresh: is the session live? Records the use. Null when it is unknown,
97
+ * revoked, expired or someone else's.
98
+ *
99
+ * @param {string} sid
100
+ * @param {{userId: string, ip?: string, ua?: string}} who
101
+ */
102
+ export async function useSession(sid, {userId, ip = '', ua = ''}) {
103
+ const store = await load();
104
+ const s = store.sessions[sid];
105
+ if (!s || s.revokedAt || s.userId !== String(userId) || Date.parse(s.expiresAt) < Date.now()) return null;
106
+ // Written at most once a minute per session: a refresh every 15 minutes is the normal rate.
107
+ if (Date.now() - Date.parse(s.lastUsedAt) > 60_000 || s.ip !== clip(ip, 64)) {
108
+ await change((st) => {
109
+ const live = st.sessions[sid];
110
+ if (live) Object.assign(live, {lastUsedAt: new Date().toISOString(), ip: clip(ip, 64), ua: clip(ua, 300) || live.ua});
111
+ });
112
+ }
113
+ return {...s, sid};
114
+ }
115
+
116
+ /** Revoke one session. True when there was a live one. */
117
+ export function revokeSession(sid, {userId = null} = {}) {
118
+ return change((store) => {
119
+ const s = store.sessions[sid];
120
+ if (!s || s.revokedAt || (userId && s.userId !== String(userId))) return false;
121
+ s.revokedAt = new Date().toISOString();
122
+ return true;
123
+ });
124
+ }
125
+
126
+ /** Revoke every live session of a user (but `except`). Returns how many. */
127
+ export function revokeUserSessions(userId, {except = null} = {}) {
128
+ return change((store) => {
129
+ let n = 0;
130
+ for (const [sid, s] of Object.entries(store.sessions)) {
131
+ if (s.userId === String(userId) && !s.revokedAt && sid !== except) {
132
+ s.revokedAt = new Date().toISOString();
133
+ n++;
134
+ }
135
+ }
136
+ return n;
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Live sessions, newest use first - one user's, or everyone's.
142
+ *
143
+ * @param {{userId?: string}} [opts]
144
+ * @returns {Promise<{sid: string, userId: string, createdAt: string, lastUsedAt: string, expiresAt: string, ip: string, ua: string}[]>}
145
+ */
146
+ export async function listSessions({userId = null} = {}) {
147
+ const store = await load();
148
+ const now = Date.now();
149
+ return Object.entries(store.sessions)
150
+ .filter(([, s]) => !s.revokedAt && Date.parse(s.expiresAt) >= now && (!userId || s.userId === String(userId)))
151
+ .map(([sid, s]) => ({sid, ...s}))
152
+ .sort((a, b) => Date.parse(b.lastUsedAt) - Date.parse(a.lastUsedAt));
153
+ }
154
+
155
+ const tokenHash = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
156
+
157
+ /** A pre-0.83 refresh token (no sid) signed out: remembered until it would have expired. */
158
+ export function revokeLegacyToken(token, expSeconds) {
159
+ const exp = Number(expSeconds) * 1000 || Date.now() + 7 * 86400000;
160
+ return change((store) => { store.legacy[tokenHash(token)] = exp; });
161
+ }
162
+
163
+ export async function isLegacyTokenRevoked(token) {
164
+ const store = await load();
165
+ return Boolean(store.legacy[tokenHash(token)]);
166
+ }
167
+
168
+ /** Tests: forget the cached store (the file stays). */
169
+ export function _resetCache() {
170
+ cache = null;
171
+ }
@@ -195,6 +195,7 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
195
195
  for (const c of PRIVATE_DATA) L.push(`/content/collections/${c}/data.json`);
196
196
  L.push(
197
197
  '/content/users/',
198
+ '/content/sessions/',
198
199
  '/content/versions/',
199
200
  '/content/analytics/',
200
201
  '/content/instances_log.json',