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.
@@ -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
- body: `The mail server refused or could not be reached: ${String(message || '').slice(0, 300)}\n\nPassword resets and form emails may not be arriving. Check Settings > Email.`});
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. */