domma-cms 0.82.0 → 0.84.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 +24 -0
- package/admin/js/views/login.js +7 -7
- 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 +46 -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 +64 -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 +135 -0
- package/plugins/security/server/state.js +106 -0
- package/plugins/security/tests/security.test.js +134 -0
- package/server/routes/api/auth.js +184 -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,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',
|