domma-cms 1.8.14 → 1.8.16
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/CHANGELOG.md +11 -0
- package/CLAUDE.md +18 -1
- package/docs/configuration.md +1 -0
- package/package.json +1 -1
- package/plugins/_lib/mail/hostGuard.js +96 -12
- package/plugins/_lib/mail/imapPool.js +26 -4
- package/plugins/_lib/mail/poller.js +2 -1
- package/plugins/_lib/mail/readRoutes.js +4 -4
- package/plugins/_lib/mail/scheduler.js +7 -4
- package/plugins/_lib/mail/send.js +67 -18
- package/plugins/free-tier.lock.json +6 -5
- package/plugins/security/CLAUDE.md +9 -1
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/server/facts.js +105 -29
- package/plugins/security/tests/facts.test.js +109 -0
- package/server/routes/api/forms.js +13 -6
- package/server/routes/api/settings.js +22 -6
- package/server/services/actions.js +5 -2
- package/server/services/adapters/MongoAdapter.js +22 -6
- package/server/services/email.js +98 -8
- package/server/services/filterEngine.js +3 -1
- package/server/services/mailHost.js +100 -0
- package/server/services/mongoQueryGuard.js +193 -0
- package/server/services/notification-sources.js +2 -1
- package/server/services/outbound.js +394 -0
- package/server/services/views.js +13 -1
- package/server/services/wpImport/fetch.js +12 -181
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this server may open a mail connection (SMTP or IMAP) to a host a site
|
|
3
|
+
* user typed - and the address it then connects to.
|
|
4
|
+
*
|
|
5
|
+
* A mail host is a TCP target the admin chooses, and a failed connection says
|
|
6
|
+
* what is listening there, so an unchecked host is a scanner of the server's
|
|
7
|
+
* own network. The address rules are services/outbound.js's (`resolvePublic`,
|
|
8
|
+
* `isBlockedAddress`): every address the name resolves to must be public,
|
|
9
|
+
* IPv4-mapped / NAT64 / decimal and octal spellings included. This module adds
|
|
10
|
+
* what is particular to mail:
|
|
11
|
+
*
|
|
12
|
+
* - Signed-only sites (codePolicy.signedOnly() - every site a Manager hosts)
|
|
13
|
+
* may name `localhost`, `127.0.0.1` or `::1` (hosted sites send through the
|
|
14
|
+
* box's own port 25) or a public mail server, on the mail ports only
|
|
15
|
+
* (SMTP 25/465/587/2525, IMAP 143/993). Nothing the site's admin sets lifts
|
|
16
|
+
* that - not `outboundAllowPrivate`, not a mail plugin's
|
|
17
|
+
* `allowPrivateMailHosts`, not the site's own SMTP host.
|
|
18
|
+
* - This server's own PUBLIC address is not refused here (outbound.js refuses
|
|
19
|
+
* it for web requests): a hosted mailbox at mail.<domain> on the same box is
|
|
20
|
+
* normal, and on a signed-only site the port list keeps it to mail.
|
|
21
|
+
* - The connection goes to the address that was checked (`address`), with the
|
|
22
|
+
* name kept for TLS (`servername`), so a second DNS answer cannot swap in an
|
|
23
|
+
* internal one (DNS rebinding).
|
|
24
|
+
*
|
|
25
|
+
* Self-hosted sites keep their freedom: core's site SMTP is not checked at all
|
|
26
|
+
* there (an internal relay is normal), and the mail plugins' own rules apply
|
|
27
|
+
* (plugins/_lib/mail/hostGuard.js).
|
|
28
|
+
*
|
|
29
|
+
* @module services/mailHost
|
|
30
|
+
*/
|
|
31
|
+
import dns from 'node:dns';
|
|
32
|
+
import net from 'node:net';
|
|
33
|
+
import {FetchRefused, resolvePublic} from './outbound.js';
|
|
34
|
+
import {signedOnly} from './codePolicy.js';
|
|
35
|
+
|
|
36
|
+
export const SMTP_PORTS = Object.freeze([25, 465, 587, 2525]);
|
|
37
|
+
export const IMAP_PORTS = Object.freeze([143, 993]);
|
|
38
|
+
|
|
39
|
+
/** The loopback names a signed-only site may use, and the address each one is. */
|
|
40
|
+
const LOOPBACK = Object.freeze({localhost: '127.0.0.1', '127.0.0.1': '127.0.0.1', '::1': '::1'});
|
|
41
|
+
|
|
42
|
+
export const REFUSED_PRIVATE = 'That mail server is on a private network, so this site will not connect to it. '
|
|
43
|
+
+ 'Use the mail server\'s public name.';
|
|
44
|
+
|
|
45
|
+
export class MailHostRefused extends Error {
|
|
46
|
+
constructor(message) { super(message); this.code = 'EMAILHOST_REFUSED'; this.mailHostRefused = true; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalise a typed host for comparison: case, spaces, trailing dot, IPv6 brackets.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} host
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function normaliseMailHost(host) {
|
|
56
|
+
return String(host ?? '').trim().toLowerCase().replace(/\.$/, '').replace(/^\[(.*)\]$/, '$1');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check a mail host and port, and say which address to connect to.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} host
|
|
63
|
+
* @param {number} port
|
|
64
|
+
* @param {object} [opts]
|
|
65
|
+
* @param {'smtp'|'imap'} [opts.kind='smtp']
|
|
66
|
+
* @param {boolean} [opts.signed] - codePolicy.signedOnly() when omitted
|
|
67
|
+
* @param {boolean} [opts.checkAddresses] - refuse private addresses (default: on when signed)
|
|
68
|
+
* @param {boolean} [opts.anyPort] - skip the port rule (a save route that has no port to hand; the connection checks it)
|
|
69
|
+
* @param {Function} [opts.lookup] - dns.promises.lookup, swappable in tests
|
|
70
|
+
* @returns {Promise<{address: string, family: number, servername: string|undefined}>}
|
|
71
|
+
* @throws {MailHostRefused} the host or port is not allowed
|
|
72
|
+
* @throws {Error} code ENOTFOUND - the name did not resolve (never connect by name instead)
|
|
73
|
+
*/
|
|
74
|
+
export async function resolveMailHost(host, port, {kind = 'smtp', signed, checkAddresses, anyPort = false, lookup = dns.promises.lookup} = {}) {
|
|
75
|
+
const isSigned = signed ?? signedOnly();
|
|
76
|
+
const check = checkAddresses ?? isSigned;
|
|
77
|
+
const name = normaliseMailHost(host);
|
|
78
|
+
if (!name) throw new MailHostRefused('A mail server is required.');
|
|
79
|
+
if (isSigned && !anyPort) {
|
|
80
|
+
const ports = kind === 'imap' ? IMAP_PORTS : SMTP_PORTS;
|
|
81
|
+
const n = Number(port);
|
|
82
|
+
if (!ports.includes(n)) throw new MailHostRefused(`Port ${Number.isFinite(n) ? n : port} is not used for mail here - use ${ports.join(', ')}.`);
|
|
83
|
+
}
|
|
84
|
+
const servername = net.isIP(name) ? undefined : name;
|
|
85
|
+
if (isSigned && Object.hasOwn(LOOPBACK, name)) {
|
|
86
|
+
const address = LOOPBACK[name];
|
|
87
|
+
return {address, family: net.isIP(address), servername};
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
// own: an empty set - see the module note on this server's public address.
|
|
91
|
+
const r = await resolvePublic(name, {allowPrivate: !check, lookup, own: new Set()});
|
|
92
|
+
return {address: r.address, family: r.family, servername};
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (!(err instanceof FetchRefused)) throw err;
|
|
95
|
+
if (/could not be found/.test(err.message)) {
|
|
96
|
+
throw Object.assign(new Error(`getaddrinfo ENOTFOUND ${name}`), {code: 'ENOTFOUND'});
|
|
97
|
+
}
|
|
98
|
+
throw new MailHostRefused(REFUSED_PRIVATE);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one gate between a user-shaped query and MongoDB.
|
|
3
|
+
*
|
|
4
|
+
* Site users shape queries in many places - saved views and the view editor,
|
|
5
|
+
* the `[view]` and `[collection]` shortcodes, collection filters, `/api/v1`
|
|
6
|
+
* query parameters, API Builder endpoints. Every one of them reaches MongoDB
|
|
7
|
+
* through one of two translations, and both go through this module:
|
|
8
|
+
*
|
|
9
|
+
* - a structured filter -> `toSafeMongoQuery()` (MongoAdapter.list)
|
|
10
|
+
* - a view pipeline -> `toSafeMongoPipeline()` (views.executeMongoView)
|
|
11
|
+
*
|
|
12
|
+
* Refused, at any depth (inside nested objects and arrays, `$lookup`
|
|
13
|
+
* sub-pipelines, `$expr`, `$group`...):
|
|
14
|
+
*
|
|
15
|
+
* - anything that runs JavaScript inside the database server: `$where`,
|
|
16
|
+
* `$function`, `$accumulator`, `$code`, and BSON Code values;
|
|
17
|
+
* - anything that writes, or reads outside the site's own collections:
|
|
18
|
+
* `$out`, `$merge`, `$unionWith`, `$graphLookup`, the server-state stages
|
|
19
|
+
* (`$currentOp`, `$collStats`, ...), and a `$lookup` that is not into one
|
|
20
|
+
* of this site's `cms_<slug>` collections (no cross-database form);
|
|
21
|
+
* - keys named `__proto__`, `constructor` or `prototype`.
|
|
22
|
+
*
|
|
23
|
+
* Plain operators (`$eq`, `$gt`, `$in`, `$regex`, `$expr` with ordinary
|
|
24
|
+
* expressions, `$group` with `$sum`...) pass untouched.
|
|
25
|
+
*
|
|
26
|
+
* @module services/mongoQueryGuard
|
|
27
|
+
*/
|
|
28
|
+
import {toMongoQuery} from './filterEngine.js';
|
|
29
|
+
|
|
30
|
+
/** Operators that execute code on the database server. */
|
|
31
|
+
export const CODE_OPERATORS = Object.freeze(['$where', '$function', '$accumulator', '$code', '$mapReduce']);
|
|
32
|
+
|
|
33
|
+
/** Operators that write, or reach data or server state beyond the site's collections. */
|
|
34
|
+
export const REACH_OPERATORS = Object.freeze([
|
|
35
|
+
'$out', '$merge', '$unionWith', '$graphLookup', '$documents',
|
|
36
|
+
'$currentOp', '$listSessions', '$listLocalSessions', '$collStats', '$indexStats',
|
|
37
|
+
'$planCacheStats', '$changeStream', '$listSearchIndexes', '$listSampledQueries', '$querySettings'
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const PROTOTYPE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
41
|
+
const REFUSED = new Set([...CODE_OPERATORS, ...REACH_OPERATORS].map(k => k.toLowerCase()));
|
|
42
|
+
const SITE_SLUG = /^[a-z0-9][a-z0-9_-]*$/;
|
|
43
|
+
const MAX_DEPTH = 64;
|
|
44
|
+
|
|
45
|
+
export class UnsafeQueryError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.code = 'EUNSAFE_QUERY';
|
|
49
|
+
this.statusCode = 400;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Throw when a query, pipeline or any part of one could run code or reach
|
|
55
|
+
* outside the site's own collections.
|
|
56
|
+
*
|
|
57
|
+
* @param {unknown} value
|
|
58
|
+
* @param {object} [opts]
|
|
59
|
+
* @param {Set<string>|string[]} [opts.collections] - the site's collection slugs; when given, a `$lookup` must name one
|
|
60
|
+
* @param {string} [opts.prefix='cms_'] - the MongoDB name prefix of a site collection
|
|
61
|
+
* @returns {unknown} the same value, for chaining
|
|
62
|
+
*/
|
|
63
|
+
export function assertSafeMongoQuery(value, opts = {}) {
|
|
64
|
+
const known = opts.collections ? new Set(opts.collections) : null;
|
|
65
|
+
const prefix = opts.prefix ?? 'cms_';
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
|
|
68
|
+
const walk = (node, at, depth) => {
|
|
69
|
+
if (node === null || typeof node !== 'object') {
|
|
70
|
+
if (typeof node === 'function') throw new UnsafeQueryError(`${at || 'The query'} is a function - not allowed in a query.`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (depth > MAX_DEPTH) throw new UnsafeQueryError('The query is nested too deeply.');
|
|
74
|
+
if (seen.has(node)) return;
|
|
75
|
+
seen.add(node);
|
|
76
|
+
// A BSON Code value runs as JavaScript wherever the server accepts one.
|
|
77
|
+
if (String(node._bsontype || '').toLowerCase().startsWith('code')) {
|
|
78
|
+
throw new UnsafeQueryError(`${at || 'The query'} holds JavaScript code - not allowed in a query.`);
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(node)) {
|
|
81
|
+
node.forEach((item, i) => walk(item, `${at}[${i}]`, depth + 1));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Own keys, including a `__proto__` key that JSON.parse makes an own property.
|
|
85
|
+
for (const key of Reflect.ownKeys(node)) {
|
|
86
|
+
if (typeof key !== 'string') continue;
|
|
87
|
+
const here = at ? `${at}.${key}` : key;
|
|
88
|
+
const lower = key.toLowerCase();
|
|
89
|
+
// A dotted path ('data.x.__proto__', 'a.$where') is checked a part at a time.
|
|
90
|
+
for (const part of key.split('.')) {
|
|
91
|
+
if (PROTOTYPE_KEYS.has(part)) throw new UnsafeQueryError(`"${part}" is not allowed as a name in a query (at ${here}).`);
|
|
92
|
+
const p = part.toLowerCase();
|
|
93
|
+
if (REFUSED.has(p)) {
|
|
94
|
+
throw new UnsafeQueryError(`${part} is not allowed in a query - it ${CODE_OPERATORS.some(k => k.toLowerCase() === p) ? 'runs code on the database server' : 'reaches beyond this site\'s own collections'} (at ${here}).`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const child = node[key];
|
|
98
|
+
if (lower === '$lookup') checkLookup(child, here, known, prefix);
|
|
99
|
+
walk(child, here, depth + 1);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
walk(value, '', 0);
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function checkLookup(config, at, known, prefix) {
|
|
108
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new UnsafeQueryError(`$lookup needs an object (at ${at}).`);
|
|
109
|
+
const from = config.from;
|
|
110
|
+
if (typeof from !== 'string') {
|
|
111
|
+
throw new UnsafeQueryError(`$lookup may only join one of this site's collections, named as text - not another database (at ${at}.from).`);
|
|
112
|
+
}
|
|
113
|
+
// `cms_<slug>` or the bare `<slug>` (the file evaluator accepts both; the
|
|
114
|
+
// MongoDB translation prefixes a bare one), never `cms__` meta stores.
|
|
115
|
+
const slug = from.startsWith(prefix) ? from.slice(prefix.length) : from;
|
|
116
|
+
if (!SITE_SLUG.test(slug)) {
|
|
117
|
+
throw new UnsafeQueryError(`$lookup may only join one of this site's collections - "${from}" is not one (at ${at}.from).`);
|
|
118
|
+
}
|
|
119
|
+
if (known && !known.has(slug)) {
|
|
120
|
+
throw new UnsafeQueryError(`$lookup names "${from}", which is not a collection on this site (at ${at}.from).`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A structured filter (filterEngine DSL) as a MongoDB query - refused when it
|
|
126
|
+
* would carry anything `assertSafeMongoQuery` refuses.
|
|
127
|
+
*
|
|
128
|
+
* @param {Record<string, unknown>|undefined} filter
|
|
129
|
+
* @returns {object}
|
|
130
|
+
*/
|
|
131
|
+
export function toSafeMongoQuery(filter) {
|
|
132
|
+
if (!filter) return {};
|
|
133
|
+
assertSafeMongoQuery(filter);
|
|
134
|
+
return assertSafeMongoQuery(toMongoQuery(filter));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every `$lookup` target named in a (checked) pipeline, at any depth.
|
|
139
|
+
*
|
|
140
|
+
* @param {unknown} value
|
|
141
|
+
* @returns {string[]} the `from` collection names
|
|
142
|
+
*/
|
|
143
|
+
export function lookupTargets(value) {
|
|
144
|
+
const out = new Set();
|
|
145
|
+
const walk = (node) => {
|
|
146
|
+
if (!node || typeof node !== 'object') return;
|
|
147
|
+
if (Array.isArray(node)) return node.forEach(walk);
|
|
148
|
+
for (const [k, v] of Object.entries(node)) {
|
|
149
|
+
if (k === '$lookup' && typeof v?.from === 'string') out.add(v.from);
|
|
150
|
+
walk(v);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
walk(value);
|
|
154
|
+
return [...out];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Stage descriptors (`{type, config}`) as a MongoDB aggregation pipeline,
|
|
159
|
+
* refused when any stage would carry anything `assertSafeMongoQuery` refuses.
|
|
160
|
+
*
|
|
161
|
+
* @param {object[]} stages
|
|
162
|
+
* @param {object} [opts]
|
|
163
|
+
* @param {Set<string>|string[]} [opts.collections] - the site's collection slugs
|
|
164
|
+
* @param {(slug: string) => Promise<boolean>} [opts.collectionExists] - asked for each `$lookup` target
|
|
165
|
+
* @returns {Promise<object[]>}
|
|
166
|
+
*/
|
|
167
|
+
export async function toSafeMongoPipeline(stages, opts = {}) {
|
|
168
|
+
const pipeline = (Array.isArray(stages) ? stages : []).map(stage => {
|
|
169
|
+
const type = String(stage?.type || '');
|
|
170
|
+
if (PROTOTYPE_KEYS.has(type)) throw new UnsafeQueryError(`"${type}" is not a stage.`);
|
|
171
|
+
return {[type]: stage.config};
|
|
172
|
+
});
|
|
173
|
+
assertSafeMongoQuery(pipeline, opts);
|
|
174
|
+
// A copy, so the saved view is not rewritten: every $lookup names its cms_ collection.
|
|
175
|
+
const out = structuredClone(pipeline);
|
|
176
|
+
const prefixLookups = (node) => {
|
|
177
|
+
if (!node || typeof node !== 'object') return;
|
|
178
|
+
if (Array.isArray(node)) return node.forEach(prefixLookups);
|
|
179
|
+
for (const [k, v] of Object.entries(node)) {
|
|
180
|
+
if (k === '$lookup' && typeof v?.from === 'string' && !v.from.startsWith('cms_')) v.from = `cms_${v.from}`;
|
|
181
|
+
prefixLookups(v);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
prefixLookups(out);
|
|
185
|
+
if (opts.collectionExists) {
|
|
186
|
+
for (const from of lookupTargets(out)) {
|
|
187
|
+
if (!(await opts.collectionExists(from.slice('cms_'.length)))) {
|
|
188
|
+
throw new UnsafeQueryError(`$lookup names "${from}", which is not a collection on this site.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
@@ -317,7 +317,8 @@ export async function registerCoreSources() {
|
|
|
317
317
|
export function emailFailed(message) {
|
|
318
318
|
return notify({source: 'core:email', severity: 'warning', dedupeKey: `email-failed:${day()}`, link: '#/settings',
|
|
319
319
|
title: 'An email could not be sent',
|
|
320
|
-
|
|
320
|
+
// `message` is email.js's plain text, never the mail server's own words (those are in the server log).
|
|
321
|
+
body: `${String(message || 'The mail server refused the message or could not be reached.').slice(0, 300)}\n\nPassword resets and form emails may not be arriving. Check Settings > Email; the server log has the mail server's reply.`});
|
|
321
322
|
}
|
|
322
323
|
|
|
323
324
|
/** email.js: no SMTP host, so mail goes to a test inbox. */
|