domma-cms 1.8.15 → 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 +6 -0
- package/CLAUDE.md +6 -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 +4 -2
- package/server/routes/api/settings.js +22 -6
- package/server/services/email.js +98 -8
- package/server/services/mailHost.js +100 -0
- package/server/services/notification-sources.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.8.16] - 2026-10-16
|
|
4
|
+
|
|
5
|
+
- **Fixed: the mail server setting could be used to probe the server's own network.** An admin could set the outgoing mail server in Site Settings to any internal address and port, and "Send test email" showed the mail library's own error, which says what is listening there. On sites a Domma CMS Manager hosts (signed-only), the mail server must now be `localhost`, `127.0.0.1`, `::1` or a public mail server, on port 25, 465, 587 or 2525; a private or internal address is refused when the setting is saved and when mail is sent, and the connection goes to the address that was checked, so a second DNS answer cannot point it somewhere else. Mailboxes in the mail tools follow the same rules there (IMAP on 143 or 993), and are checked at every connection rather than only when saved. On every site, a failed test email now says "Could not send the test email. Check the mail server settings." and the full error goes to the server log only; the same goes for the failed-email notice and the mail tools' connection errors. Self-hosted sites can still send through an internal relay.
|
|
6
|
+
|
|
7
|
+
- **Fixed: the Security Tool's health check could be pointed at another server.** It fetched the address the incoming request named. It now checks only the site's own address (Security 1.2.1).
|
|
8
|
+
|
|
3
9
|
## [1.8.15] - 2026-10-16
|
|
4
10
|
|
|
5
11
|
- **Fixed: a view or filter on a MongoDB collection could run JavaScript inside the database server.** A saved view, the view editor's live results, a `[view]` or `[collection]` on a page, a collection filter, an `/api/v1` query or an API Builder endpoint could carry `$where`, `$function`, `$accumulator` and the like - hidden at any depth - and MongoDB would run it. Every query is now checked in one place before MongoDB sees it, and refused with a plain message if it holds anything that runs code, writes (`$out`, `$merge`), reads beyond the site's own collections (`$unionWith`, `$graphLookup`, a `$lookup` into another database or something that is not one of the site's collections) or uses a name like `__proto__`. A view is checked when it is saved, too. Ordinary filters and views work as before; a `$lookup` that names a collection by its plain name (`people` rather than `cms_people`) now works on MongoDB as it already did on files. An entry id sent as anything but text (`{"$ne": ""}` in an Action's run request, say) no longer picks whichever MongoDB entry matched first.
|
package/CLAUDE.md
CHANGED
|
@@ -136,6 +136,12 @@ Collection entry I/O is delegated to a storage adapter. Schema management is alw
|
|
|
136
136
|
connection pinned to the checked address, never this server's own addresses, redirects re-checked, ports 80/443.
|
|
137
137
|
`server.json` `outboundAllowPrivate` (`true` or a list of hosts/addresses/CIDRs) lifts it for self-hosters and is
|
|
138
138
|
ignored when `codePolicy.signedOnly()`. `wpImport/fetch.js` shares its address rules and pinned request
|
|
139
|
+
- **Mail hosts a user typed go through `services/mailHost.js` `resolveMailHost()`** (outbound.js's address rules). On a
|
|
140
|
+
signed-only site: `localhost`/`127.0.0.1`/`::1` or a public server, ports 25/465/587/2525 (IMAP 143/993), connected by
|
|
141
|
+
the checked address with the name as TLS `servername`; nothing the site sets lifts it. `email.js` `createTransport()`
|
|
142
|
+
does it per send there; self-hosted site SMTP is unchecked (internal relays are normal). The mail plugins' connections
|
|
143
|
+
(`_lib/mail` imapPool, send.js) re-check and pin through `hostGuard.js` `pinMailHost()`. Send errors reach the browser
|
|
144
|
+
as plain words only (`email.js` `mailFailure()` logs the raw text): the server's words say what listens on a port.
|
|
139
145
|
|
|
140
146
|
## Roles & Permissions
|
|
141
147
|
|
package/package.json
CHANGED
|
@@ -11,14 +11,22 @@
|
|
|
11
11
|
* the admin chose) or `allowPrivateMailHosts` is set for a site whose mail
|
|
12
12
|
* server really is on its LAN.
|
|
13
13
|
*
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
14
|
+
* The address rules are core's (services/outbound.js `isBlockedAddress`, and
|
|
15
|
+
* services/mailHost.js on a signed-only site, where nothing above lifts them);
|
|
16
|
+
* `isLocalName` is unsubscribe.js's. Checking a name and then connecting by
|
|
17
|
+
* name lets a second DNS answer point the connection inward, so the
|
|
18
|
+
* connections themselves (imapPool.js, send.js) go through `pinMailHost()`:
|
|
19
|
+
* checked again, and connected to the address that was checked.
|
|
16
20
|
*
|
|
17
21
|
* @module _lib/mail/hostGuard
|
|
18
22
|
*/
|
|
19
23
|
import dns from 'node:dns/promises';
|
|
20
24
|
import net from 'node:net';
|
|
21
|
-
import {
|
|
25
|
+
import {getConfig} from '../../../server/config.js';
|
|
26
|
+
import {isBlockedAddress} from '../../../server/services/outbound.js';
|
|
27
|
+
import {MailHostRefused, normaliseMailHost, resolveMailHost} from '../../../server/services/mailHost.js';
|
|
28
|
+
import {signedOnly} from '../../../server/services/codePolicy.js';
|
|
29
|
+
import {isLocalName} from './unsubscribe.js';
|
|
22
30
|
|
|
23
31
|
const REFUSED = 'That server is on this machine\'s own network, so the site will not connect to it. '
|
|
24
32
|
+ 'Use the mail server\'s public name, or ask the site administrator to allow private mail hosts.';
|
|
@@ -30,33 +38,58 @@ const REFUSED = 'That server is on this machine\'s own network, so the site will
|
|
|
30
38
|
* @returns {string}
|
|
31
39
|
*/
|
|
32
40
|
export function normaliseHost(host) {
|
|
33
|
-
return
|
|
41
|
+
return normaliseMailHost(host);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Does `allowPrivate` / `ownSmtpHost` let this (self-hosted) site reach the name anywhere? */
|
|
45
|
+
function exempt(name, {allowPrivate, ownSmtpHost}) {
|
|
46
|
+
return allowPrivate === true || (!!ownSmtpHost && normaliseHost(ownSmtpHost) === name);
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
/**
|
|
37
|
-
* Check one host.
|
|
50
|
+
* Check one host (the save and "test these details" routes).
|
|
51
|
+
*
|
|
52
|
+
* On a signed-only (hosted) site the rules are core's services/mailHost.js and
|
|
53
|
+
* nothing the site's admin sets lifts them: loopback names or public servers,
|
|
54
|
+
* on the mail ports when `port` is given. `allowPrivate` and `ownSmtpHost`
|
|
55
|
+
* count on self-hosted sites only.
|
|
56
|
+
*
|
|
57
|
+
* Passing is not the end of it: every connection checks again and connects to
|
|
58
|
+
* the address it checked (`pinMailHost`), so a name that resolves differently
|
|
59
|
+
* later (DNS rebinding) is caught there.
|
|
38
60
|
*
|
|
39
61
|
* @param {string} host
|
|
40
62
|
* @param {object} [options]
|
|
41
63
|
* @param {boolean} [options.allowPrivate] - the site's `allowPrivateMailHosts` setting
|
|
42
64
|
* @param {string} [options.ownSmtpHost] - the site's configured outgoing server; always allowed
|
|
43
|
-
* @param {
|
|
65
|
+
* @param {number} [options.port] - checked against the mail ports on a signed-only site
|
|
66
|
+
* @param {'smtp'|'imap'} [options.kind] - which mail ports
|
|
67
|
+
* @param {boolean} [options.signed] - codePolicy.signedOnly() when omitted
|
|
68
|
+
* @param {Function} [options.lookup] - DNS, injectable
|
|
44
69
|
* @returns {Promise<{ok: true} | {ok: false, error: string}>}
|
|
45
70
|
*/
|
|
46
|
-
export async function checkMailHost(host, {allowPrivate = false, ownSmtpHost = '', lookup} = {}) {
|
|
71
|
+
export async function checkMailHost(host, {allowPrivate = false, ownSmtpHost = '', lookup, port, kind = 'imap', signed} = {}) {
|
|
47
72
|
const name = normaliseHost(host);
|
|
48
73
|
if (!name) return {ok: false, error: 'A server is required.'};
|
|
49
|
-
if (
|
|
50
|
-
|
|
74
|
+
if (signed ?? signedOnly()) {
|
|
75
|
+
try {
|
|
76
|
+
await resolveMailHost(name, port, {kind, signed: true, anyPort: port === undefined, ...(lookup && {lookup})});
|
|
77
|
+
return {ok: true};
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err.mailHostRefused) return {ok: false, error: err.message};
|
|
80
|
+
return {ok: true}; // unresolvable: the connection attempt says so
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (exempt(name, {allowPrivate, ownSmtpHost})) return {ok: true};
|
|
51
84
|
|
|
52
85
|
if (net.isIP(name)) {
|
|
53
|
-
return
|
|
86
|
+
return isBlockedAddress(name) ? {ok: false, error: REFUSED} : {ok: true};
|
|
54
87
|
}
|
|
55
88
|
if (isLocalName(name)) return {ok: false, error: REFUSED};
|
|
56
89
|
|
|
57
90
|
let answers;
|
|
58
91
|
try {
|
|
59
|
-
answers = await (lookup ?? ((h) => dns.lookup(h, {all: true})))(name);
|
|
92
|
+
answers = await (lookup ?? ((h) => dns.lookup(h, {all: true, verbatim: true})))(name, {all: true, verbatim: true});
|
|
60
93
|
} catch {
|
|
61
94
|
// Unresolvable: let the connection attempt say so in its own words.
|
|
62
95
|
// The check is about where a name points, not whether it exists.
|
|
@@ -65,10 +98,61 @@ export async function checkMailHost(host, {allowPrivate = false, ownSmtpHost = '
|
|
|
65
98
|
// Every answer must be public: a name with one private A record among
|
|
66
99
|
// public ones is exactly the rebinding trick the check exists for.
|
|
67
100
|
const addresses = (answers ?? []).map(a => (typeof a === 'string' ? a : a?.address)).filter(Boolean);
|
|
68
|
-
if (addresses.some(
|
|
101
|
+
if (addresses.some(isBlockedAddress)) return {ok: false, error: REFUSED};
|
|
69
102
|
return {ok: true};
|
|
70
103
|
}
|
|
71
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Is `allowPrivateMailHosts` on for a mail plugin on this site? Read from
|
|
107
|
+
* config/plugins.json for the connections made without a plugin's config in
|
|
108
|
+
* hand (email-pro's SMTP test). Never consulted on a signed-only site.
|
|
109
|
+
*
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
export function pluginsAllowPrivate() {
|
|
113
|
+
try {
|
|
114
|
+
const plugins = getConfig('plugins') || {};
|
|
115
|
+
return ['mail-reader', 'email-pro'].some(n => plugins[n]?.settings?.allowPrivateMailHosts === true);
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check a host at the moment of connecting, and say which address to connect
|
|
123
|
+
* to - the address that was checked, so a second DNS answer cannot swap an
|
|
124
|
+
* internal one in (DNS rebinding). The name is returned as `servername` for
|
|
125
|
+
* TLS (SNI and the certificate check).
|
|
126
|
+
*
|
|
127
|
+
* @param {string} host
|
|
128
|
+
* @param {number} port
|
|
129
|
+
* @param {object} [options]
|
|
130
|
+
* @param {'smtp'|'imap'} [options.kind='imap']
|
|
131
|
+
* @param {boolean} [options.allowPrivate] - pluginsAllowPrivate() when omitted
|
|
132
|
+
* @param {string} [options.ownSmtpHost] - siteSmtpHost(getConfig) when omitted
|
|
133
|
+
* @param {boolean} [options.signed]
|
|
134
|
+
* @param {Function} [options.lookup]
|
|
135
|
+
* @returns {Promise<{address: string, family: number, servername: string|undefined}>}
|
|
136
|
+
* @throws {Error} MailHostRefused (`mailHostRefused`), or code ENOTFOUND
|
|
137
|
+
*/
|
|
138
|
+
export async function pinMailHost(host, port, {kind = 'imap', allowPrivate, ownSmtpHost, signed, lookup} = {}) {
|
|
139
|
+
const name = normaliseHost(host);
|
|
140
|
+
const isSigned = signed ?? signedOnly();
|
|
141
|
+
const opts = {kind, signed: isSigned, ...(lookup && {lookup})};
|
|
142
|
+
if (isSigned) return resolveMailHost(name, port, opts);
|
|
143
|
+
const free = exempt(name, {
|
|
144
|
+
allowPrivate: allowPrivate ?? pluginsAllowPrivate(),
|
|
145
|
+
ownSmtpHost: ownSmtpHost ?? siteSmtpHost(getConfig)
|
|
146
|
+
});
|
|
147
|
+
if (!free && !net.isIP(name) && isLocalName(name)) throw new MailHostRefused(REFUSED);
|
|
148
|
+
try {
|
|
149
|
+
return await resolveMailHost(name, port, {...opts, checkAddresses: !free});
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err.mailHostRefused) throw new MailHostRefused(REFUSED);
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
72
156
|
/**
|
|
73
157
|
* The site's own outgoing server, from config/site.json `smtp.host` (what
|
|
74
158
|
* core `createTransport` sends with), read through a getter so a test can
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* @module mail-reader/services/imapPool
|
|
28
28
|
*/
|
|
29
29
|
import {ImapFlow} from 'imapflow';
|
|
30
|
+
import {pinMailHost} from './hostGuard.js';
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Identify a connection's target. A change to any of these means the cached
|
|
@@ -98,8 +99,16 @@ export function createPool(config) {
|
|
|
98
99
|
auth = {user: auth.user, accessToken: r.accessToken};
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// Checked here, at every connect, and connected to the checked address
|
|
103
|
+
// (the name kept for TLS) - see hostGuard.js pinMailHost.
|
|
104
|
+
const pinned = await pinMailHost(connection.host, connection.port, {kind: 'imap', allowPrivate: config.allowPrivateMailHosts === true});
|
|
105
|
+
|
|
101
106
|
const attempt = async (withAuth) => {
|
|
102
|
-
const client = new ImapFlow(
|
|
107
|
+
const client = new ImapFlow({
|
|
108
|
+
...clientOptions({...connection, auth: withAuth}),
|
|
109
|
+
host: pinned.address,
|
|
110
|
+
...(pinned.servername && {servername: pinned.servername})
|
|
111
|
+
});
|
|
103
112
|
prepare(client);
|
|
104
113
|
try {
|
|
105
114
|
await client.connect();
|
|
@@ -300,7 +309,10 @@ export function createPool(config) {
|
|
|
300
309
|
// Never hold the process open on account of an idle mail connection.
|
|
301
310
|
reaper.unref?.();
|
|
302
311
|
|
|
303
|
-
return {withClient, withMailbox, test, release, releaseUser, closeAll, connectClient,
|
|
312
|
+
return {withClient, withMailbox, test, release, releaseUser, closeAll, connectClient,
|
|
313
|
+
// What send.js passes to hostGuard.js pinMailHost for this plugin's outgoing servers.
|
|
314
|
+
mailHostOptions: {allowPrivate: config.allowPrivateMailHosts === true},
|
|
315
|
+
get size() {
|
|
304
316
|
return pool.size;
|
|
305
317
|
}};
|
|
306
318
|
}
|
|
@@ -321,7 +333,7 @@ export function createPool(config) {
|
|
|
321
333
|
*/
|
|
322
334
|
export function describeError(err, connection = {}) {
|
|
323
335
|
if (!err) return 'Unknown error.';
|
|
324
|
-
if (err.needsReconnect) return err.message;
|
|
336
|
+
if (err.needsReconnect || err.mailHostRefused) return err.message;
|
|
325
337
|
// A signed-in (OAuth2) mailbox has no password to blame.
|
|
326
338
|
if ((err.authenticationFailed || err.code === 'EAUTH' || err.code === 'EOAUTH2') && (connection.auth?.accessToken !== undefined || connection.auth?.type === 'OAuth2')) {
|
|
327
339
|
return 'The mail server refused the sign-in. If it keeps happening, Reconnect the mailbox.';
|
|
@@ -366,6 +378,16 @@ export function describeError(err, connection = {}) {
|
|
|
366
378
|
case 'ECONNRESET':
|
|
367
379
|
return 'The mail server did not respond in time.';
|
|
368
380
|
default:
|
|
369
|
-
|
|
381
|
+
// Words that are ours, or a mail server's answer to a command after
|
|
382
|
+
// it greeted and (for IMAP) signed us in - those say nothing about
|
|
383
|
+
// what else listens on a host and port.
|
|
384
|
+
if (err.name === 'MailKeyError' || err.responseStatus === 'NO' || err.responseStatus === 'BAD'
|
|
385
|
+
|| err.code === 'EENVELOPE' || err.code === 'EMESSAGE') {
|
|
386
|
+
return err.message || 'The mail server connection failed.';
|
|
387
|
+
}
|
|
388
|
+
// Anything else stays out of the browser: a greeting, a banner or a
|
|
389
|
+
// socket error is exactly what a port scan reads. The log has it.
|
|
390
|
+
console.warn(`[mail] connection to ${connection.host || 'the mail server'}:${connection.port || '?'} failed${err.code ? ` (${err.code})` : ''}: ${err.message || err}`);
|
|
391
|
+
return 'The mail server connection failed. Check the server settings.';
|
|
370
392
|
}
|
|
371
393
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* @module _lib/mail/poller
|
|
14
14
|
*/
|
|
15
15
|
import {selectDuePolls} from './pollSchedule.js';
|
|
16
|
+
import {describeError} from './imapPool.js';
|
|
16
17
|
|
|
17
18
|
/** How often the ticker looks for work. Finer than any poll interval. */
|
|
18
19
|
const TICK_MS = 30_000;
|
|
@@ -49,7 +50,7 @@ export function createPoller({listAccounts, syncAccount, defaultMinutes, logger,
|
|
|
49
50
|
} catch (err) {
|
|
50
51
|
const failures = (entry.failures ?? 0) + 1;
|
|
51
52
|
state.set(account.id, {
|
|
52
|
-
lastAttemptAt: Date.now(), failures, running: false, lastError: err
|
|
53
|
+
lastAttemptAt: Date.now(), failures, running: false, lastError: describeError(err)
|
|
53
54
|
});
|
|
54
55
|
// Warn, not error: a mail server being unreachable is an ordinary
|
|
55
56
|
// condition, and the backoff already handles it.
|
|
@@ -240,7 +240,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
/** Where this server may be asked to connect - see hostGuard.js. */
|
|
243
|
-
const guardHost = (host) => checkMailHost(host, {allowPrivate: config.allowPrivateMailHosts === true, ownSmtpHost: siteSmtpHost(getConfig)});
|
|
243
|
+
const guardHost = (host, port) => checkMailHost(host, {allowPrivate: config.allowPrivateMailHosts === true, ownSmtpHost: siteSmtpHost(getConfig), port, kind: 'imap'});
|
|
244
244
|
// Each test is a connection attempt whose answer says what is listening: a few a minute per address is plenty.
|
|
245
245
|
const testLimit = {config: {rateLimit: {max: 10, timeWindow: '1 minute'}}};
|
|
246
246
|
|
|
@@ -258,7 +258,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
258
258
|
|
|
259
259
|
const result = accounts.validate(request.body ?? {}, null);
|
|
260
260
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
261
|
-
const where = await guardHost(result.value.host);
|
|
261
|
+
const where = await guardHost(result.value.host, result.value.port);
|
|
262
262
|
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
263
263
|
|
|
264
264
|
const saved = await accounts.saveAccount(uid, null, result.value);
|
|
@@ -277,7 +277,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
277
277
|
const result = accounts.validate(request.body ?? {}, existing);
|
|
278
278
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
279
279
|
// A signed-in mailbox's servers are the provider's and cannot be edited here.
|
|
280
|
-
const where = result.value.oauth ? {ok: true} : await guardHost(result.value.host);
|
|
280
|
+
const where = result.value.oauth ? {ok: true} : await guardHost(result.value.host, result.value.port);
|
|
281
281
|
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
282
282
|
|
|
283
283
|
const saved = await accounts.saveAccount(uid, request.params.id, result.value);
|
|
@@ -343,7 +343,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
343
343
|
throw err;
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
|
-
const where = await guardHost(result.value.host);
|
|
346
|
+
const where = await guardHost(result.value.host, result.value.port);
|
|
347
347
|
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
348
348
|
|
|
349
349
|
let pass = result.value.password;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import * as accounts from './accounts.js';
|
|
26
26
|
import {sendMessage} from './send.js';
|
|
27
|
+
import {describeError} from './imapPool.js';
|
|
27
28
|
|
|
28
29
|
/** How often the ticker looks for work. */
|
|
29
30
|
const TICK_MS = 5_000;
|
|
@@ -118,11 +119,13 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
|
|
|
118
119
|
// the sender is told (the send-failed notice) instead of waiting.
|
|
119
120
|
const giveUp = err?.needsReconnect || (item.attempts ?? 1) >= MAX_ATTEMPTS;
|
|
120
121
|
await store.finishSend(item._id, giveUp
|
|
121
|
-
|
|
122
|
+
// describeError: a queue item is read in the browser, and the mail
|
|
123
|
+
// server's own words are for the log (they say what listens on a port).
|
|
124
|
+
? {status: 'failed', error: err?.needsReconnect ? reconnectReason(err) : describeError(err)}
|
|
122
125
|
// Back to pending, a little later. `finishedAt` is set either
|
|
123
126
|
// way, which is harmless on a retry and is what tells a
|
|
124
127
|
// screen when the last attempt was.
|
|
125
|
-
: {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: err
|
|
128
|
+
: {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: describeError(err)});
|
|
126
129
|
|
|
127
130
|
logger.warn(
|
|
128
131
|
`[mail] queued send ${item._id} failed (attempt ${item.attempts}): ${err.message}`
|
|
@@ -186,8 +189,8 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
|
|
|
186
189
|
const attempts = (item.attempts ?? 0) + 1;
|
|
187
190
|
const giveUp = err?.needsReconnect || attempts >= MAX_ATTEMPTS;
|
|
188
191
|
await store.finishSnooze(item._id, giveUp
|
|
189
|
-
? {status: 'failed', error: err?.needsReconnect ? `${reconnectReason(err)} The message is still in ${item.folder}.` : err
|
|
190
|
-
: {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: err
|
|
192
|
+
? {status: 'failed', error: err?.needsReconnect ? `${reconnectReason(err)} The message is still in ${item.folder}.` : describeError(err), attempts}
|
|
193
|
+
: {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: describeError(err), attempts});
|
|
191
194
|
logger.warn(
|
|
192
195
|
`[mail] could not wake snoozed message ${item._id} (attempt ${attempts}): ${err.message}`
|
|
193
196
|
);
|
|
@@ -14,6 +14,7 @@ import nodemailer from 'nodemailer';
|
|
|
14
14
|
|
|
15
15
|
import {bareAddress, formatFrom} from '../admin/mail/identity.js';
|
|
16
16
|
import {describeError} from './imapPool.js';
|
|
17
|
+
import {pinMailHost} from './hostGuard.js';
|
|
17
18
|
import {normalisePriority, priorityHeaders} from './priority.js';
|
|
18
19
|
import {normaliseMessageSource, recordMessageAction, SOURCE_HEADER} from './message-action.js';
|
|
19
20
|
|
|
@@ -213,22 +214,31 @@ export async function sendMessage({
|
|
|
213
214
|
// running its own mail server with a self-signed certificate has the same
|
|
214
215
|
// problem in both directions, and two switches for one decision is how
|
|
215
216
|
// one of them ends up wrong.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
217
|
+
// The mail server's own words never leave as the error (they say what
|
|
218
|
+
// listens on a port): describeError's plain text, the detail in the log.
|
|
219
|
+
let transport;
|
|
220
|
+
let info;
|
|
221
|
+
try {
|
|
222
|
+
transport = nodemailer.createTransport({
|
|
223
|
+
...await pinnedTransport(smtp.transport, pool?.mailHostOptions),
|
|
224
|
+
tls: {rejectUnauthorized: !allowInsecureTLS}
|
|
225
|
+
});
|
|
226
|
+
info = await transport.sendMail({
|
|
227
|
+
envelope: {
|
|
228
|
+
// Bare, always. A display name here is not decoration the server
|
|
229
|
+
// ignores - it is a MAIL FROM the server rejects, and `smtp.from`
|
|
230
|
+
// is a free-text field somebody may well have typed a name into.
|
|
231
|
+
from: bareAddress(smtp.from),
|
|
232
|
+
to: [...addresses(draft.to), ...addresses(draft.cc), ...addresses(draft.bcc)]
|
|
233
|
+
.map(a => a.address)
|
|
234
|
+
},
|
|
235
|
+
raw
|
|
236
|
+
});
|
|
237
|
+
} catch (err) {
|
|
238
|
+
throw plainSendError(err, smtp.transport);
|
|
239
|
+
} finally {
|
|
240
|
+
transport?.close();
|
|
241
|
+
}
|
|
232
242
|
|
|
233
243
|
const messageAction = await recordMessageAction({
|
|
234
244
|
source: draft.sourceMessage, account, pool, poolKey, connection, store
|
|
@@ -383,15 +393,54 @@ async function findSentFolder(pool, poolKey, connection) {
|
|
|
383
393
|
return findSpecialFolder(pool, poolKey, connection, '\\Sent');
|
|
384
394
|
}
|
|
385
395
|
|
|
396
|
+
/**
|
|
397
|
+
* A send failure as plain words. Our own errors (a refused host, a mailbox that
|
|
398
|
+
* needs signing in again) pass as they are; anything from the server or the
|
|
399
|
+
* library becomes describeError()'s text, keeping `code` so a caller can still
|
|
400
|
+
* tell an authentication failure from the rest.
|
|
401
|
+
*
|
|
402
|
+
* @param {Error} err
|
|
403
|
+
* @param {object} transportConfig
|
|
404
|
+
* @returns {Error}
|
|
405
|
+
*/
|
|
406
|
+
export function plainSendError(err, transportConfig = {}) {
|
|
407
|
+
if (!err || err.needsReconnect || err.mailHostRefused || err.name === 'MailKeyError') return err;
|
|
408
|
+
const plain = new Error(describeError(err, transportConfig));
|
|
409
|
+
if (err.code) plain.code = err.code;
|
|
410
|
+
if (err.responseCode) plain.responseCode = err.responseCode;
|
|
411
|
+
return plain;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A mailbox's transport settings with the host checked and pinned: connect to
|
|
416
|
+
* the address hostGuard.js checked, keep the name for TLS. Throws when the
|
|
417
|
+
* host or port is refused, or the name does not resolve.
|
|
418
|
+
*
|
|
419
|
+
* @param {object} transportConfig - {host, port, ...}
|
|
420
|
+
* @param {{allowPrivate?: boolean}} [guard] - the plugin's allowPrivateMailHosts, when the caller has it
|
|
421
|
+
* @returns {Promise<object>}
|
|
422
|
+
*/
|
|
423
|
+
export async function pinnedTransport(transportConfig, guard = {}) {
|
|
424
|
+
const port = Number(transportConfig.port) || (transportConfig.secure ? 465 : 587);
|
|
425
|
+
const {address, servername} = await pinMailHost(transportConfig.host, port, {kind: 'smtp', ...(guard || {})});
|
|
426
|
+
return {...transportConfig, port, host: address, ...(servername && {servername})};
|
|
427
|
+
}
|
|
428
|
+
|
|
386
429
|
/**
|
|
387
430
|
* Try the outgoing server without sending anything.
|
|
388
431
|
*
|
|
389
432
|
* @param {object} transportConfig
|
|
390
433
|
* @returns {Promise<{ok: boolean, error?: string}>}
|
|
391
434
|
*/
|
|
392
|
-
export async function verifySmtp(transportConfig, allowInsecureTLS = false) {
|
|
435
|
+
export async function verifySmtp(transportConfig, allowInsecureTLS = false, guard = undefined) {
|
|
436
|
+
let pinned;
|
|
437
|
+
try {
|
|
438
|
+
pinned = await pinnedTransport(transportConfig, guard);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
return {ok: false, error: describeError(err, transportConfig)};
|
|
441
|
+
}
|
|
393
442
|
const transport = nodemailer.createTransport({
|
|
394
|
-
...
|
|
443
|
+
...pinned,
|
|
395
444
|
connectionTimeout: 10_000,
|
|
396
445
|
tls: {rejectUnauthorized: !allowInsecureTLS}
|
|
397
446
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
|
|
3
|
-
"source": "dcms-marketplace@
|
|
3
|
+
"source": "dcms-marketplace@af8baa8",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.10.1",
|
|
@@ -165,9 +165,9 @@
|
|
|
165
165
|
}
|
|
166
166
|
},
|
|
167
167
|
"security": {
|
|
168
|
-
"version": "1.2.
|
|
168
|
+
"version": "1.2.1",
|
|
169
169
|
"files": {
|
|
170
|
-
"CLAUDE.md": "
|
|
170
|
+
"CLAUDE.md": "1aabef04e77d9bd10ead195851a75c358b5c6657a57ad217495cf7b5b0d63538",
|
|
171
171
|
"admin/lib/blocks.js": "5ef346b7de751518f8beb9212e3b89b270ae236560ae3d8a1f40d129c50918ff",
|
|
172
172
|
"admin/lib/health.js": "3b233a3d29b2d69c333321602c13233b8221cfe1114aa03903739d9ae8ad65d5",
|
|
173
173
|
"admin/lib/ip.js": "cf33be4fa2e2cad37bafd12ebec17f293891a88de402caad1bf9c9a57ce87680",
|
|
@@ -183,11 +183,12 @@
|
|
|
183
183
|
"config.js": "e6a00f7816637687d6a68ebbac65a71489246a926c28523c2a78d859e75b7ae0",
|
|
184
184
|
"docs/guide.md": "a27d5303f9146057fd79c2093d7476ada1a5ee4fd14d34df7f94ad8555d88ca0",
|
|
185
185
|
"plugin.js": "9d3a2e8b56dd637407c8abd93252dda9f67042231bc79d35771f766a62022c14",
|
|
186
|
-
"plugin.json": "
|
|
186
|
+
"plugin.json": "c7ebf55a71048e914ca8e6d2f042fb099123367e1e6fd694cfbf9eb2e4146852",
|
|
187
187
|
"server/common-passwords.NOTICE": "3b148a49a569bfa24feaa7879db602da8f0607fa61ab6c52b7a62391fc7c009a",
|
|
188
188
|
"server/common-passwords.txt": "68782d6a4a19a4768d5f15dd66bd534e7a33055cc755411e33f16d18c50fdcce",
|
|
189
|
-
"server/facts.js": "
|
|
189
|
+
"server/facts.js": "8dbf6802acd9103fe4ca8d5e7d2dfc97c169df6d47bea72b33411cd22fee597b",
|
|
190
190
|
"server/state.js": "1319b322e94a4aec176948990b2d674b28dfece761eef4d5685df474037bfc4a",
|
|
191
|
+
"tests/facts.test.js": "dc59d307033c7a9887b4d522bccd9e9d4986b6ed5cb4ffe1287282a93f54cb11",
|
|
191
192
|
"tests/screen-state.test.js": "5349273cf7cfe526a05f929115a6e4e66287e6fb05b252ea859e182e88e3925e",
|
|
192
193
|
"tests/security.test.js": "f0cb70dcb28bc8b3de8d3b39064616296382c5b0f97bfd615ee8e24fe3b031bc"
|
|
193
194
|
}
|
|
@@ -24,7 +24,10 @@ Ships with the engine (free tier, `make sync-free-tier`); switched ON when it fi
|
|
|
24
24
|
`kind: 'reset'` lines - their `ok` is not a sign-in. Older engines never emit the event: nothing changes there.
|
|
25
25
|
- **Health check**: `server/facts.js` gathers (users, engine vs npm latest, certificate via tls, security headers of the
|
|
26
26
|
public home page, private paths fetched LOCALLY, TRUST_PROXY, bind, SMTP, licences); `admin/lib/health.js` judges.
|
|
27
|
-
Unknown = 'info', never a pass.
|
|
27
|
+
Unknown = 'info', never a pass. The public checks (headers, HTTPS, certificate) connect ONLY to `publicTarget()`:
|
|
28
|
+
site.json `baseUrl`/`url` when it is a public name (every DNS answer public), else the admin's own Host but only
|
|
29
|
+
when it resolves to one of this server's public interface addresses - never a host a visitor names (1.2.1). The
|
|
30
|
+
connection is pinned to the checked address (no second DNS lookup). Cached 10 min; the sidebar badge (`/count`) = checks needing attention, red only
|
|
28
31
|
when a failing check is critical.
|
|
29
32
|
- Engines before 0.83 have no sign-in checks: the plugin logs it, the screen says so, lockout/rules are off.
|
|
30
33
|
- plugin.json `minCmsVersion: "0.83.0"` (1.1.1) states that floor; core enforces it at install/update from the release after 0.92.1,
|
|
@@ -63,3 +66,8 @@ what strangers typed). Public data (emails, addresses, user-agents) only through
|
|
|
63
66
|
`security.rule-draft.<user>`. Pure parts in `admin/lib/screen-state.js` (Pro's copy adds Audit and 2FA).
|
|
64
67
|
- Every list: click/Enter/Shift+F10/right-click/⋮ open the row menu (a check row opens its details on click). Filter
|
|
65
68
|
counts follow the search; skeletons; help popovers started after mount. Keep this screen alike with Security Pro's.
|
|
69
|
+
|
|
70
|
+
## 1.2.1 (2026-10-16)
|
|
71
|
+
|
|
72
|
+
- Health check: the headers/certificate fetch no longer goes to whatever Host / X-Forwarded-Host a request carried
|
|
73
|
+
(a forged one made the server connect anywhere). `publicTarget()` in server/facts.js; `tests/facts.test.js`.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "security",
|
|
3
3
|
"displayName": "Security",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.1",
|
|
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-10-
|
|
8
|
+
"date": "2026-10-16",
|
|
9
9
|
"minCmsVersion": "0.83.0",
|
|
10
10
|
"icon": "shield",
|
|
11
11
|
"permissions": [
|
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Slow answers (the latest engine version, the certificate) are cached.
|
|
7
7
|
*/
|
|
8
|
+
import dns from 'node:dns';
|
|
8
9
|
import fs from 'node:fs';
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import https from 'node:https';
|
|
12
|
+
import net from 'node:net';
|
|
13
|
+
import os from 'node:os';
|
|
9
14
|
import path from 'node:path';
|
|
10
15
|
import tls from 'node:tls';
|
|
11
16
|
import {getConfig} from '../../../server/config.js';
|
|
@@ -21,6 +26,7 @@ async function cached(key, ms, fn) {
|
|
|
21
26
|
const hit = cache.get(key);
|
|
22
27
|
if (hit && hit.until > Date.now()) return hit.value;
|
|
23
28
|
const value = await fn();
|
|
29
|
+
if (cache.size > 200) for (const [k, v] of cache) if (v.until <= Date.now()) cache.delete(k);
|
|
24
30
|
cache.set(key, {value, until: Date.now() + ms});
|
|
25
31
|
return value;
|
|
26
32
|
}
|
|
@@ -41,11 +47,11 @@ async function engine() {
|
|
|
41
47
|
return {installed, latest};
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
/** Days left on the certificate the site serves (cached 6 h). */
|
|
45
|
-
function certificate(host) {
|
|
46
|
-
return cached(`cert:${host}`, 6 * 3_600_000, () => new Promise((resolve) => {
|
|
50
|
+
/** Days left on the certificate the site serves (cached 6 h). Connects only to the checked address. */
|
|
51
|
+
export function certificate({host, address, port = 443}) {
|
|
52
|
+
return cached(`cert:${host}:${address}`, 6 * 3_600_000, () => new Promise((resolve) => {
|
|
47
53
|
const done = (v) => { try { socket.destroy(); } catch { /* gone */ } resolve(v); };
|
|
48
|
-
const socket = tls.connect({host, port
|
|
54
|
+
const socket = tls.connect({host: address, port, servername: host, rejectUnauthorized: false, timeout: TIMEOUT}, () => {
|
|
49
55
|
const c = socket.getPeerCertificate();
|
|
50
56
|
if (!c?.valid_to) return done({daysLeft: null, error: 'no certificate'});
|
|
51
57
|
done({daysLeft: Math.floor((Date.parse(c.valid_to) - Date.now()) / 86_400_000), issuer: c.issuer?.O || c.issuer?.CN || ''});
|
|
@@ -55,23 +61,34 @@ function certificate(host) {
|
|
|
55
61
|
}));
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
/** The security headers the public home page is served with. */
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
64
|
+
/** The security headers the public home page is served with. Connects only to the checked address. */
|
|
65
|
+
export function headers({host, proto, address, port}) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
let settled = false;
|
|
68
|
+
const finish = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
69
|
+
const secure = proto === 'https';
|
|
70
|
+
const req = (secure ? https : http).request({
|
|
71
|
+
host: address, port: port || (secure ? 443 : 80), path: '/', method: 'GET', agent: false,
|
|
72
|
+
...(secure ? {servername: host} : {}),
|
|
73
|
+
headers: {host, 'user-agent': 'Domma Security health check'}
|
|
74
|
+
}, (res) => {
|
|
75
|
+
const h = (n) => String(res.headers[n] || '');
|
|
76
|
+
const csp = h('content-security-policy');
|
|
77
|
+
res.resume();
|
|
78
|
+
finish({
|
|
79
|
+
https: secure,
|
|
80
|
+
csp: Boolean(csp),
|
|
81
|
+
nosniff: /nosniff/i.test(h('x-content-type-options')),
|
|
82
|
+
frame: Boolean(h('x-frame-options')) || /frame-ancestors/i.test(csp),
|
|
83
|
+
referrer: Boolean(h('referrer-policy')),
|
|
84
|
+
hsts: Boolean(h('strict-transport-security'))
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
const timer = setTimeout(() => { finish({error: 'timed out'}); req.destroy(); }, TIMEOUT);
|
|
88
|
+
req.on('error', (e) => finish({error: e.code || e.message}));
|
|
89
|
+
req.on('close', () => clearTimeout(timer));
|
|
90
|
+
req.end();
|
|
91
|
+
});
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
/** Private paths asked of the CMS itself (locally, so Security's own probe filter does not hide them). */
|
|
@@ -91,18 +108,76 @@ async function probeFiles(port) {
|
|
|
91
108
|
|
|
92
109
|
const isLocalHost = (h) => !h || h === 'localhost' || /^[\d.]+$/.test(h) || h.includes(':') || h.endsWith('.local') || h.endsWith('.test');
|
|
93
110
|
|
|
111
|
+
/** Not a public address: loopback, private, link-local, CGNAT, unique-local, unspecified, multicast. */
|
|
112
|
+
export function isPrivateAddress(ip) {
|
|
113
|
+
const a = String(ip || '').toLowerCase().replace(/^::ffff:(?=\d+\.)/, '');
|
|
114
|
+
if (net.isIPv4(a)) {
|
|
115
|
+
const [o1, o2] = a.split('.').map(Number);
|
|
116
|
+
return o1 === 0 || o1 === 10 || o1 === 127 || o1 >= 224 || (o1 === 169 && o2 === 254) || (o1 === 172 && o2 >= 16 && o2 <= 31)
|
|
117
|
+
|| (o1 === 192 && o2 === 168) || (o1 === 100 && o2 >= 64 && o2 <= 127) || (o1 === 192 && o2 === 0) || (o1 === 198 && (o2 === 18 || o2 === 19));
|
|
118
|
+
}
|
|
119
|
+
// IPv6: loopback, unspecified, IPv4-compatible/mapped (anything under ::/16), NAT64 (64:ff9b::), 6to4 (2002::),
|
|
120
|
+
// unique-local, link-local, site-local, multicast - an embedded IPv4 is never trusted to be public.
|
|
121
|
+
if (net.isIPv6(a)) return a.startsWith('::') || /^64:ff9b:/.test(a) || /^2002:/.test(a) || /^(fc|fd|fe[89a-f]|ff)/.test(a);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** This machine's own public addresses (its network interfaces, not loopback or private ones). */
|
|
126
|
+
export function ownPublicAddresses() {
|
|
127
|
+
return new Set(Object.values(os.networkInterfaces()).flat().filter(i => i && !i.internal)
|
|
128
|
+
.map(i => String(i.address).toLowerCase().replace(/^::ffff:(?=\d+\.)/, '').split('%')[0]).filter(a => !isPrivateAddress(a)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const urlHost = (raw) => {
|
|
132
|
+
try {
|
|
133
|
+
const u = new URL(String(raw || ''));
|
|
134
|
+
return /^https?:$/.test(u.protocol) ? {host: u.hostname.toLowerCase(), proto: u.protocol.slice(0, -1)} : null;
|
|
135
|
+
} catch { return null; }
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Where the public checks (headers, HTTPS, certificate) may connect - never a host named by a
|
|
140
|
+
* visitor. The site's configured address (site.json `baseUrl`, else `url`) when it has a public
|
|
141
|
+
* name (a configured name that fails the checks gives null, never a fallback); otherwise the name the admin used, but ONLY when it resolves to one of this server's own
|
|
142
|
+
* public addresses (a hosted site has no configured address, and its name points here). Either
|
|
143
|
+
* way the connection is pinned to the checked address, so a second DNS answer cannot move it.
|
|
144
|
+
* Null = leave those checks unanswered (as on localhost).
|
|
145
|
+
*
|
|
146
|
+
* @returns {Promise<{host: string, proto: 'http'|'https', address: string, source: 'config'|'request'}|null>}
|
|
147
|
+
*/
|
|
148
|
+
export async function publicTarget(request, {site = getConfig('site') || {}, lookup = dns.promises.lookup, own = ownPublicAddresses()} = {}) {
|
|
149
|
+
const resolve = async (host) => {
|
|
150
|
+
try { return (await lookup(host, {all: true})).map(r => String(r.address).toLowerCase().replace(/^::ffff:(?=\d+\.)/, '')); }
|
|
151
|
+
catch { return []; }
|
|
152
|
+
};
|
|
153
|
+
const configured = urlHost(site.baseUrl) || urlHost(site.url);
|
|
154
|
+
if (configured && !isLocalHost(configured.host)) {
|
|
155
|
+
const addrs = await resolve(configured.host);
|
|
156
|
+
// Every answer must be public (or this server's own): an address the admin typed is still not a way inside.
|
|
157
|
+
if (!addrs.length || !addrs.every(a => own.has(a) || !isPrivateAddress(a))) return null;
|
|
158
|
+
return {...configured, address: addrs[0], source: 'config'};
|
|
159
|
+
}
|
|
160
|
+
// A local configured address (the shipped default is http://localhost:4096) says nothing about the public name.
|
|
161
|
+
|
|
162
|
+
const host = String(request.hostname || '').split(':')[0].toLowerCase();
|
|
163
|
+
if (isLocalHost(host) || !/^[a-z0-9.-]+$/.test(host)) return null;
|
|
164
|
+
const mine = (await resolve(host)).find(a => own.has(a));
|
|
165
|
+
if (!mine) return null;
|
|
166
|
+
const proto = request.protocol === 'https' || request.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
167
|
+
return {host, proto, address: mine, source: 'request'};
|
|
168
|
+
}
|
|
169
|
+
|
|
94
170
|
/**
|
|
95
171
|
* Everything the health check needs.
|
|
96
172
|
*
|
|
97
|
-
* @param {import('fastify').FastifyRequest} request - the admin asking
|
|
173
|
+
* @param {import('fastify').FastifyRequest} request - the admin asking
|
|
98
174
|
* @param {{settings: object, checks: boolean, twoFactor?: boolean}} ctx
|
|
175
|
+
* @param {{target?: object}} [deps] - tests: options for publicTarget()
|
|
99
176
|
*/
|
|
100
|
-
export async function gatherFacts(request, {settings, checks, twoFactor = false}) {
|
|
177
|
+
export async function gatherFacts(request, {settings, checks, twoFactor = false}, deps = {}) {
|
|
101
178
|
const server = readJson(path.join(CMS_ROOT, 'config', 'server.json')) || {};
|
|
102
179
|
const port = Number(process.env.PORT) || server.port || 4096;
|
|
103
|
-
const
|
|
104
|
-
const proto = request.protocol === 'https' || request.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
105
|
-
const origin = isLocalHost(host) ? `http://127.0.0.1:${port}` : `${proto}://${host}`;
|
|
180
|
+
const target = await publicTarget(request, deps.target);
|
|
106
181
|
|
|
107
182
|
const users = (await listUsers()).map(u => ({name: u.name, email: u.email, role: u.role, lastLogin: u.lastLogin || null,
|
|
108
183
|
createdAt: u.createdAt || null, isActive: u.isActive !== false, level: getEffectiveLevel(u)}));
|
|
@@ -111,10 +186,11 @@ export async function gatherFacts(request, {settings, checks, twoFactor = false}
|
|
|
111
186
|
|
|
112
187
|
const [eng, cert, head, probes] = await Promise.all([
|
|
113
188
|
engine(),
|
|
114
|
-
|
|
189
|
+
target ? certificate(target) : Promise.resolve(null),
|
|
115
190
|
// Headers and HTTPS are about the PUBLIC site: without a public name to ask (a check run by the site
|
|
116
|
-
// itself, or
|
|
117
|
-
|
|
191
|
+
// itself, on localhost, or a name that is not this server's) they are left unanswered rather than
|
|
192
|
+
// judged on the local port.
|
|
193
|
+
target ? headers(target) : Promise.resolve(null),
|
|
118
194
|
probeFiles(port)
|
|
119
195
|
]);
|
|
120
196
|
const smtp = getConfig('site')?.smtp || {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Security - the health check never connects to a host a visitor names (Host / X-Forwarded-Host). */
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import Fastify from 'fastify';
|
|
7
|
+
import {certificate, gatherFacts, headers, isPrivateAddress, publicTarget} from '../server/facts.js';
|
|
8
|
+
|
|
9
|
+
const OWN = '82.165.196.228';
|
|
10
|
+
const DNS = {'evil.example': ['203.0.113.9'], 'inside.example': ['127.0.0.1'], 'lan.example': ['10.0.0.5'],
|
|
11
|
+
'site.example': [OWN], 'mixed.example': ['10.0.0.5', OWN], 'configured.example': ['198.51.100.7']};
|
|
12
|
+
|
|
13
|
+
/** A fake resolver that records every name it was asked about. */
|
|
14
|
+
function resolver() {
|
|
15
|
+
const asked = [];
|
|
16
|
+
const lookup = async (host) => {
|
|
17
|
+
asked.push(host);
|
|
18
|
+
if (!DNS[host]) throw Object.assign(new Error('ENOTFOUND'), {code: 'ENOTFOUND'});
|
|
19
|
+
return DNS[host].map(address => ({address, family: address.includes(':') ? 6 : 4}));
|
|
20
|
+
};
|
|
21
|
+
return {asked, lookup};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Run publicTarget() on a real Fastify request (trustProxy on, so X-Forwarded-Host is believed - the worst case). */
|
|
25
|
+
async function targetFor(headers, deps) {
|
|
26
|
+
const app = Fastify({trustProxy: true});
|
|
27
|
+
app.get('/t', async (request) => ({target: await publicTarget(request, deps)}));
|
|
28
|
+
const res = await app.inject({method: 'GET', url: '/t', headers});
|
|
29
|
+
await app.close();
|
|
30
|
+
return res.json().target;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('a forged Host or X-Forwarded-Host is never a target', async () => {
|
|
34
|
+
for (const headers of [
|
|
35
|
+
{host: 'evil.example'},
|
|
36
|
+
{host: 'site.example', 'x-forwarded-host': 'evil.example'},
|
|
37
|
+
{host: 'inside.example'},
|
|
38
|
+
{host: 'site.example', 'x-forwarded-host': 'lan.example'},
|
|
39
|
+
{host: 'site.example', 'x-forwarded-host': 'nowhere.example'},
|
|
40
|
+
{host: '169.254.169.254'},
|
|
41
|
+
{host: 'site.example', 'x-forwarded-host': '10.0.0.5:8080'}
|
|
42
|
+
]) {
|
|
43
|
+
const {lookup} = resolver();
|
|
44
|
+
assert.equal(await targetFor(headers, {site: {}, lookup, own: new Set([OWN])}), null, JSON.stringify(headers));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the name the admin used is checked only when it resolves to this server, and pinned there', async () => {
|
|
49
|
+
const {lookup} = resolver();
|
|
50
|
+
const t = await targetFor({host: 'site.example', 'x-forwarded-proto': 'https'}, {site: {url: 'http://localhost:4096'}, lookup, own: new Set([OWN])});
|
|
51
|
+
assert.deepEqual(t, {host: 'site.example', proto: 'https', address: OWN, source: 'request'});
|
|
52
|
+
// Several answers: the connection goes to this server's own one, never the other.
|
|
53
|
+
const m = await targetFor({host: 'mixed.example'}, {site: {}, lookup, own: new Set([OWN])});
|
|
54
|
+
assert.equal(m.address, OWN);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a configured public address wins, and the request headers are not even looked up', async () => {
|
|
58
|
+
const {asked, lookup} = resolver();
|
|
59
|
+
const t = await targetFor({host: 'evil.example', 'x-forwarded-host': 'evil.example'},
|
|
60
|
+
{site: {baseUrl: 'https://configured.example'}, lookup, own: new Set([OWN])});
|
|
61
|
+
assert.deepEqual(t, {host: 'configured.example', proto: 'https', address: '198.51.100.7', source: 'config'});
|
|
62
|
+
assert.deepEqual(asked, ['configured.example']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a configured address that resolves inside is refused, with no fallback to the request', async () => {
|
|
66
|
+
const {asked, lookup} = resolver();
|
|
67
|
+
assert.equal(await targetFor({host: 'site.example'}, {site: {url: 'https://lan.example'}, lookup, own: new Set([OWN])}), null);
|
|
68
|
+
assert.deepEqual(asked, ['lan.example']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('gatherFacts with forged headers leaves the public checks unanswered and connects nowhere', async () => {
|
|
72
|
+
const {asked, lookup} = resolver();
|
|
73
|
+
const request = {hostname: 'evil.example', protocol: 'https',
|
|
74
|
+
headers: {host: 'evil.example', 'x-forwarded-host': 'evil.example', 'x-forwarded-proto': 'https'}};
|
|
75
|
+
const settings = {lockout: {enabled: true}, policy: {enabled: true, minLength: 10}};
|
|
76
|
+
const started = Date.now();
|
|
77
|
+
const facts = await gatherFacts(request, {settings, checks: true}, {target: {site: {}, lookup, own: new Set([OWN])}});
|
|
78
|
+
assert.equal(facts.headers, null);
|
|
79
|
+
assert.equal(facts.cert, null);
|
|
80
|
+
assert.deepEqual(asked, ['evil.example']);
|
|
81
|
+
assert.ok(Date.now() - started < 20_000);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('isPrivateAddress', () => {
|
|
85
|
+
for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '192.0.0.8', '198.18.0.1',
|
|
86
|
+
'::', '::1', '::7f00:1', '::ffff:127.0.0.1', '64:ff9b::a00:1', '2002:7f00:1::1', 'fd00::1', 'fe80::1', 'fec0::1', 'ff02::1', 'nonsense'])
|
|
87
|
+
assert.equal(isPrivateAddress(a), true, a);
|
|
88
|
+
for (const a of [OWN, '8.8.8.8', '2a00:1450:4009::1'])
|
|
89
|
+
assert.equal(isPrivateAddress(a), false, a);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('the checks connect to the pinned address, sending the name only as Host / SNI', async () => {
|
|
93
|
+
const seen = [];
|
|
94
|
+
const web = http.createServer((req, res) => { seen.push(req.headers.host); res.setHeader('x-content-type-options', 'nosniff'); res.end('ok'); });
|
|
95
|
+
await new Promise(r => web.listen(0, '127.0.0.1', r));
|
|
96
|
+
const h = await headers({host: 'site.example', proto: 'http', address: '127.0.0.1', port: web.address().port});
|
|
97
|
+
web.close();
|
|
98
|
+
assert.deepEqual(seen, ['site.example']);
|
|
99
|
+
assert.equal(h.nosniff, true);
|
|
100
|
+
assert.equal(h.https, false);
|
|
101
|
+
|
|
102
|
+
let connected = 0;
|
|
103
|
+
const raw = net.createServer((sock) => { connected++; sock.destroy(); });
|
|
104
|
+
await new Promise(r => raw.listen(0, '127.0.0.1', r));
|
|
105
|
+
const c = await certificate({host: 'pinned.example', address: '127.0.0.1', port: raw.address().port});
|
|
106
|
+
raw.close();
|
|
107
|
+
assert.equal(connected, 1);
|
|
108
|
+
assert.equal(c.daysLeft, null);
|
|
109
|
+
});
|
|
@@ -27,7 +27,7 @@ import {deleteForm, ensureCollectionForForm, ensureFormsDir, listForms, readForm
|
|
|
27
27
|
import {executeAction} from '../../services/actions.js';
|
|
28
28
|
// The same rules the browser draws the buttons by (a Cancel link must be safe).
|
|
29
29
|
import {buttonSettingsProblems, formClassProblems, publicButtonSettings} from '../../../public/js/form-submit.mjs';
|
|
30
|
-
import {createTransport, sendFormEmail} from '../../services/email.js';
|
|
30
|
+
import {createTransport, mailFailure, sendFormEmail, TEST_EMAIL_FAILED} from '../../services/email.js';
|
|
31
31
|
import {formActionFailed, formTriggerNotice} from '../../services/notification-sources.js';
|
|
32
32
|
import {
|
|
33
33
|
clearEntries,
|
|
@@ -1063,7 +1063,9 @@ export async function formsRoutes(fastify) {
|
|
|
1063
1063
|
});
|
|
1064
1064
|
return { ok: true, message: `Test email sent to ${to}` };
|
|
1065
1065
|
} catch (err) {
|
|
1066
|
-
|
|
1066
|
+
// Plain words only - the server's own are in the log (email.js mailFailure).
|
|
1067
|
+
const plain = mailFailure(err);
|
|
1068
|
+
return reply.status(500).send({ error: plain.refused ? `${TEST_EMAIL_FAILED} ${plain.message}` : TEST_EMAIL_FAILED });
|
|
1067
1069
|
}
|
|
1068
1070
|
});
|
|
1069
1071
|
}
|
|
@@ -11,7 +11,9 @@ import {faviconSource, generateFavicon} from '../../services/favicon.js';
|
|
|
11
11
|
import {saveMedia} from '../../services/content.js';
|
|
12
12
|
import {randomUUID} from 'node:crypto';
|
|
13
13
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
14
|
-
import {createTransport} from '../../services/email.js';
|
|
14
|
+
import {createTransport, mailFailure, TEST_EMAIL_FAILED} from '../../services/email.js';
|
|
15
|
+
import {signedOnly} from '../../services/codePolicy.js';
|
|
16
|
+
import {resolveMailHost} from '../../services/mailHost.js';
|
|
15
17
|
import fs from 'fs/promises';
|
|
16
18
|
import path from 'path';
|
|
17
19
|
import {fileURLToPath} from 'url';
|
|
@@ -171,6 +173,17 @@ export async function settingsRoutes(fastify) {
|
|
|
171
173
|
if (merged.smtp && merged.smtp.pass === '' && existing.smtp?.pass) {
|
|
172
174
|
merged.smtp = {...merged.smtp, pass: existing.smtp.pass};
|
|
173
175
|
}
|
|
176
|
+
// Hosted sites: refuse a mail server this site would not connect to, at
|
|
177
|
+
// save, so it is not found out at the first password reset.
|
|
178
|
+
if (signedOnly() && merged.smtp?.host
|
|
179
|
+
&& (merged.smtp.host !== existing.smtp?.host || merged.smtp.port !== existing.smtp?.port)) {
|
|
180
|
+
try {
|
|
181
|
+
await resolveMailHost(merged.smtp.host, Number(merged.smtp.port) || 587, {kind: 'smtp', signed: true});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err.mailHostRefused) return reply.code(400).send({error: err.message});
|
|
184
|
+
// A name that does not resolve yet is the send's problem, not the save's.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
174
187
|
saveConfig('site', merged);
|
|
175
188
|
await cache.invalidateTags(['site']);
|
|
176
189
|
return { success: true };
|
|
@@ -182,16 +195,16 @@ export async function settingsRoutes(fastify) {
|
|
|
182
195
|
return reply.status(400).send({ error: 'SMTP is not configured. Save your SMTP settings first.' });
|
|
183
196
|
}
|
|
184
197
|
|
|
185
|
-
// Shared transport factory - carries the self-signed-cert TLS relaxation.
|
|
186
|
-
// Do NOT build a raw nodemailer transport here; that regresses on engine sync.
|
|
187
|
-
const transporter = await createTransport(smtp);
|
|
188
|
-
|
|
189
198
|
const to = request.body?.to || smtp.fromAddress;
|
|
190
199
|
if (!to) {
|
|
191
200
|
return reply.status(400).send({ error: 'No recipient address. Provide "to" in the request body or set a From Address.' });
|
|
192
201
|
}
|
|
193
202
|
|
|
194
203
|
try {
|
|
204
|
+
// Shared transport factory - carries the self-signed-cert TLS relaxation
|
|
205
|
+
// and, on hosted sites, the mail-host rules (services/mailHost.js).
|
|
206
|
+
// Do NOT build a raw nodemailer transport here; that regresses on engine sync.
|
|
207
|
+
const transporter = await createTransport(smtp);
|
|
195
208
|
await transporter.sendMail({
|
|
196
209
|
from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromAddress}>` : smtp.fromAddress,
|
|
197
210
|
to,
|
|
@@ -201,7 +214,10 @@ export async function settingsRoutes(fastify) {
|
|
|
201
214
|
});
|
|
202
215
|
return { success: true, message: `Test email sent to ${to}` };
|
|
203
216
|
} catch (err) {
|
|
204
|
-
|
|
217
|
+
// Never the mail server's words: they say what listens on a host and
|
|
218
|
+
// port. The detail is in the server log (mailFailure).
|
|
219
|
+
const plain = mailFailure(err);
|
|
220
|
+
return reply.status(500).send({ error: plain.refused ? `${TEST_EMAIL_FAILED} ${plain.message}` : TEST_EMAIL_FAILED });
|
|
205
221
|
}
|
|
206
222
|
});
|
|
207
223
|
|
package/server/services/email.js
CHANGED
|
@@ -3,6 +3,82 @@
|
|
|
3
3
|
* Nodemailer transport factory and generic form email sending utility.
|
|
4
4
|
*/
|
|
5
5
|
import nodemailer from 'nodemailer';
|
|
6
|
+
import {signedOnly} from './codePolicy.js';
|
|
7
|
+
import {resolveMailHost} from './mailHost.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a person is told when mail could not be sent. The mail server's own
|
|
11
|
+
* words (and the library's) go to the server log only: they say what is
|
|
12
|
+
* listening on a host and port, which is how a mail setting becomes a scanner
|
|
13
|
+
* of the server's network.
|
|
14
|
+
*/
|
|
15
|
+
export const MAIL_SEND_FAILED = 'Could not send the email. Check the mail server settings.';
|
|
16
|
+
export const TEST_EMAIL_FAILED = 'Could not send the test email. Check the mail server settings.';
|
|
17
|
+
|
|
18
|
+
/** The error every send fails with: plain words, nothing from the server. */
|
|
19
|
+
export class MailSendError extends Error {
|
|
20
|
+
constructor(message = MAIL_SEND_FAILED, {refused = false} = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.code = 'EMAILSEND';
|
|
23
|
+
this.mailSendFailed = true;
|
|
24
|
+
// The host or port was refused by services/mailHost.js - the message is ours, not the server's.
|
|
25
|
+
this.refused = refused;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Log a send failure in full, and hand back the plain error to throw.
|
|
31
|
+
*
|
|
32
|
+
* @param {unknown} err
|
|
33
|
+
* @returns {MailSendError}
|
|
34
|
+
*/
|
|
35
|
+
export function mailFailure(err) {
|
|
36
|
+
if (err instanceof MailSendError) return err;
|
|
37
|
+
const detail = err?.message || String(err);
|
|
38
|
+
console.warn(`[email] send failed${err?.code ? ` (${err.code})` : ''}: ${detail}`);
|
|
39
|
+
return err?.mailHostRefused ? new MailSendError(err.message, {refused: true}) : new MailSendError();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Wrap sendMail/verify (promise or callback style) so only plain errors leave. */
|
|
43
|
+
function guardTransportErrors(transport) {
|
|
44
|
+
for (const method of ['sendMail', 'verify']) {
|
|
45
|
+
const real = transport[method]?.bind(transport);
|
|
46
|
+
if (!real) continue;
|
|
47
|
+
transport[method] = (...args) => {
|
|
48
|
+
const cb = typeof args.at(-1) === 'function' ? args.pop() : null;
|
|
49
|
+
const p = Promise.resolve().then(() => real(...args)).catch(err => { throw mailFailure(err); });
|
|
50
|
+
if (!cb) return p;
|
|
51
|
+
p.then(info => cb(null, info), err => cb(err));
|
|
52
|
+
return undefined;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return transport;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A transport for a signed-only site: each send checks the host again and
|
|
60
|
+
* connects to the address it checked (the name kept for TLS), so neither a
|
|
61
|
+
* changed setting nor a second DNS answer can point it inward. nodemailer's SMTP
|
|
62
|
+
* transport opens a connection per message anyway, so a transport per send
|
|
63
|
+
* costs nothing extra.
|
|
64
|
+
*/
|
|
65
|
+
function pinnedTransport(base, {lookup} = {}) {
|
|
66
|
+
const open = async () => {
|
|
67
|
+
const {address, servername} = await resolveMailHost(base.host, base.port, {kind: 'smtp', signed: true, lookup});
|
|
68
|
+
return nodemailer.createTransport({...base, host: address, ...(servername && {servername})});
|
|
69
|
+
};
|
|
70
|
+
const run = async (method, args) => {
|
|
71
|
+
const t = await open();
|
|
72
|
+
try { return await t[method](...args); } finally { t.close(); }
|
|
73
|
+
};
|
|
74
|
+
return guardTransportErrors({
|
|
75
|
+
options: {...base},
|
|
76
|
+
sendMail: (message) => run('sendMail', [message]),
|
|
77
|
+
verify: () => run('verify', []),
|
|
78
|
+
close: () => {},
|
|
79
|
+
isIdle: () => true
|
|
80
|
+
});
|
|
81
|
+
}
|
|
6
82
|
|
|
7
83
|
let lastSendResult = null;
|
|
8
84
|
|
|
@@ -70,15 +146,27 @@ export function isSmtpConfigured(smtp) {
|
|
|
70
146
|
* @returns {Promise<import('nodemailer').Transporter>}
|
|
71
147
|
* @throws {Error} If Ethereal test account creation fails.
|
|
72
148
|
*/
|
|
73
|
-
export async function createTransport(smtp) {
|
|
149
|
+
export async function createTransport(smtp, {signed, lookup} = {}) {
|
|
74
150
|
if (isSmtpConfigured(smtp)) {
|
|
75
|
-
|
|
151
|
+
const base = {
|
|
76
152
|
host: smtp.host,
|
|
77
|
-
port: smtp.port || 587,
|
|
153
|
+
port: Number(smtp.port) || 587,
|
|
78
154
|
secure: smtp.secure || false,
|
|
79
155
|
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
|
|
80
156
|
tls: { rejectUnauthorized: false }
|
|
81
|
-
}
|
|
157
|
+
};
|
|
158
|
+
// Hosted (signed-only) sites: loopback or a public mail server on a mail
|
|
159
|
+
// port, connected by the checked address - services/mailHost.js.
|
|
160
|
+
// Self-hosted sites may use any relay, internal ones included, as before.
|
|
161
|
+
if (signed ?? signedOnly()) {
|
|
162
|
+
try {
|
|
163
|
+
await resolveMailHost(base.host, base.port, {kind: 'smtp', signed: true, lookup});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
throw mailFailure(err);
|
|
166
|
+
}
|
|
167
|
+
return pinnedTransport(base, {lookup});
|
|
168
|
+
}
|
|
169
|
+
return guardTransportErrors(nodemailer.createTransport(base));
|
|
82
170
|
}
|
|
83
171
|
|
|
84
172
|
// No SMTP configured - use Ethereal for dev/demo.
|
|
@@ -152,8 +240,9 @@ export async function sendEmail(transport, {from, fromName, to, subject, html, t
|
|
|
152
240
|
}
|
|
153
241
|
return info;
|
|
154
242
|
} catch (err) {
|
|
155
|
-
|
|
156
|
-
|
|
243
|
+
const plain = mailFailure(err);
|
|
244
|
+
recordSendResult(false, plain.message);
|
|
245
|
+
throw plain;
|
|
157
246
|
}
|
|
158
247
|
}
|
|
159
248
|
|
|
@@ -213,7 +302,8 @@ export async function sendFormEmail(transport, { from, fromName, to, subject, fo
|
|
|
213
302
|
}
|
|
214
303
|
return info;
|
|
215
304
|
} catch (err) {
|
|
216
|
-
|
|
217
|
-
|
|
305
|
+
const plain = mailFailure(err);
|
|
306
|
+
recordSendResult(false, plain.message);
|
|
307
|
+
throw plain;
|
|
218
308
|
}
|
|
219
309
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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. */
|