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 CHANGED
@@ -1,5 +1,16 @@
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
+
9
+ ## [1.8.15] - 2026-10-16
10
+
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.
12
+ - **Fixed: webhooks could call addresses inside the server's own network.** An Action's webhook step and a form's webhook would send a request anywhere - `http://127.0.0.1:…`, a private LAN address, or the cloud metadata address. They now reach public addresses only: the name is looked up first and every address it gives must be public and not this server's own, the request goes to the address that was checked (so a second lookup cannot swap in an internal one), redirects into a private address are refused, and only ports 80 and 443 are used. A webhook's own headers (an API key, say) are not passed on when it redirects to another site, and it cannot set `Host`. A form's webhook that answers with an error is now reported like any other failed form action. Self-hosted sites that send webhooks to their own network can allow it in `config/server.json` with `outboundAllowPrivate` - `true`, or a list of host names, addresses and ranges. It has no effect on a site its host runs signed-only (every site Domma CMS Manager hosts).
13
+
3
14
  ## [1.8.14] - 2026-10-16
4
15
 
5
16
  - **Signed-only sites close every way of putting plugin code on the server.** With `requireSignedPlugins: true` in `config/server.json` (or `DOMMA_SIGNED_ONLY=1` in the environment, which Domma CMS Manager sets on the sites it hosts), unsigned plugin files are refused straight away with a plain message - no "install anyway?" question first - and New plugin and changes in a plugin's code editor are refused too: the Marketplace screen leaves New plugin off, and the code editor opens read only. Installing and updating from the Marketplace work as before. A licensed plugin's source stays closed on such a site, Super Admins included. MongoDB connection options that could start a program or read a file on the server (`autoEncryption`, `tls…File` and the like) are refused there as well. Self-hosted sites, where the setting is off, are unchanged.
package/CLAUDE.md CHANGED
@@ -125,6 +125,23 @@ Collection entry I/O is delegated to a storage adapter. Schema management is alw
125
125
  - System preset collections (`presetCollections.js` - roles, user profiles, projects, notifications, api tokens/endpoints) are **always** file-based whatever their schema says, and cannot be moved (`isFixedToFile()`)
126
126
  - **Exception - the user-data presets** (`USER_DATA_PRESET_SLUGS`: `notes-notes`, `todo-items`, `contacts-contacts`, `contacts-groups`): `resolveUserDataPreset()` in `adapterRegistry.js` uses MongoDB only when the schema declares it (notes and todo do by default, contacts declare file), the named connection is connected, AND their `data.json` holds no rows; otherwise files. Rows in files (or in both stores) keep it on files and are logged with counts (`getStorageConflicts()`), because switching would show users an empty store - the Storage move (id-preserving) settles it. No connection configured = files, silently (MongoDB is Pro); configured but down = files + the storage-down notice. `seedAll()` keeps these presets' existing `storage` block, so a move survives a restart
127
127
  - Absence of `config/connections.json` = file mode (server.js wraps Mongo init in try/catch)
128
+ - **User-shaped queries reach MongoDB only through `mongoQueryGuard.js`**: `toSafeMongoQuery()` (MongoAdapter.list - so every
129
+ filter source: `[collection]`, `/api/v1` params, API Builder) and `toSafeMongoPipeline()` (views' native aggregation;
130
+ `validateStages()` runs the same check on save). Refused at any depth, dotted keys a part at a time: code
131
+ (`$where`, `$function`, `$accumulator`, BSON Code), writes/other data (`$out`, `$merge`, `$unionWith`,
132
+ `$graphLookup`, server-state stages, a `$lookup` not into a site `cms_<slug>` collection) and `__proto__`/`constructor`/
133
+ `prototype`. A new path that hands user input to `find`/`aggregate` must go through one of the two
134
+ - **Outbound requests to user-chosen URLs go through `services/outbound.js` `safeFetch()`** (Actions' webhook step,
135
+ form webhooks; plugins fetching a user's URL should too) - never bare `fetch`. Public addresses only after DNS, the
136
+ connection pinned to the checked address, never this server's own addresses, redirects re-checked, ports 80/443.
137
+ `server.json` `outboundAllowPrivate` (`true` or a list of hosts/addresses/CIDRs) lifts it for self-hosters and is
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.
128
145
 
129
146
  ## Roles & Permissions
130
147
 
@@ -367,7 +384,7 @@ restarting) mid-run - keep that file's name and meaning.
367
384
  - **The XML reader is its own** (`xml.js`): any DOCTYPE or ENTITY is refused, only the predefined and numeric
368
385
  entities decode - do not swap in a parser that expands entities. Zips (`zip.js`) are read by offset with
369
386
  zip-slip names dropped and ratio/size caps; nothing is ever extracted under its own name.
370
- - **Media downloads go through `fetch.js` only**: the export's own hosts, every resolved address public, the
387
+ - **Media downloads go through `fetch.js` only** (address rules and pinned request shared with `services/outbound.js`): the export's own hosts, every resolved address public, the
371
388
  connection pinned to the checked address, never the server's own interface addresses, ports 80/443 only, no
372
389
  redirect off-host; a non-image/PDF body that sniffs as HTML or script is refused (it would be served from /media).
373
390
  `WP_IMPORT_ALLOW_PRIVATE_HOSTS=1` (ignored when `NODE_ENV=production`) is the tests' and local browser checks' way
@@ -31,6 +31,7 @@ Controls the HTTP server.
31
31
  | `logging.level` | string | Log verbosity: `fatal`, `error`, `warn`, `info`, `debug`, `trace`, `silent`. Defaults to `info` in development, `warn` otherwise. `warn` hides 4xx responses and plugin `log.info` output, so a failed admin action leaves no trace - raise it to `info` if you need to diagnose one. Overridden by the `LOG_LEVEL` env var. |
32
32
  | `restartOnPluginToggle` | boolean | `true` | Restart the server automatically when an admin enables or disables a plugin. See **Plugin toggles restart the server** below |
33
33
  | `requireSignedPlugins` | boolean | `false` | When `true`, the site runs only plugin code the Marketplace signed: an unsigned `.dcmsplugin` (e.g. one exported from another site) is refused even if the installer confirms it, New plugin is refused, a plugin's files cannot be changed in the code editor (it opens read only), licensed source cannot be read even by a Super Admin, and MongoDB connection options that start a program or read a file (`autoEncryption`, `tls…File`, …) cannot be saved. A bundle with a signature that does not verify is always refused, whatever this is set to. Read fresh on every check, and also as it was at boot, so changing the file on a running site can switch it on but not off. `DOMMA_SIGNED_ONLY=1` in the environment turns it on whatever the file says - Domma CMS Manager sets it on the sites it hosts. Nothing in the admin writes `server.json`. See `server/services/codePolicy.js` |
34
+ | `outboundAllowPrivate` | boolean \| string[] | `false` | Webhooks (an Action's webhook step, a form's webhook) reach public addresses only: the name is looked up and every address must be public and not the server's own, the request is pinned to that address, redirects into private addresses are refused, and only ports 80 and 443 are used. A self-hosted site that sends webhooks inside its own network sets `true` to lift those rules for every request, or a list of host names, addresses and CIDR ranges (`["n8n.lan", "192.168.10.0/24", "fd00::/8"]`) to lift them for those only - a name passes if it is listed, or if every address it resolves to is. Ignored on a signed-only site (`requireSignedPlugins` / `DOMMA_SIGNED_ONLY=1`), so a hosted site's admin cannot open the host's network. Read fresh on every request. See `server/services/outbound.js` |
34
35
 
35
36
  ### Plugin toggles restart the server
36
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.14",
3
+ "version": "1.8.16",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -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 pure half is `isPrivateAddress` / `isLocalName` in unsubscribe.js; this
15
- * module adds the lookup, which the tests inject.
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 {isLocalName, isPrivateAddress} from './unsubscribe.js';
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 String(host ?? '').trim().toLowerCase().replace(/\.$/, '').replace(/^\[(.*)\]$/, '$1');
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 {(host: string) => Promise<{address: string}[]>} [options.lookup] - DNS, injectable
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 (allowPrivate) return {ok: true};
50
- if (ownSmtpHost && normaliseHost(ownSmtpHost) === name) return {ok: true};
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 isPrivateAddress(name) ? {ok: false, error: REFUSED} : {ok: true};
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(isPrivateAddress)) return {ok: false, error: REFUSED};
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(clientOptions({...connection, auth: withAuth}));
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, get size() {
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
- return err.message || 'The mail server connection failed.';
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.message
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
- ? {status: 'failed', error: err?.needsReconnect ? reconnectReason(err) : err.message}
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.message});
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.message, attempts}
190
- : {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: err.message, attempts});
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
- const transport = nodemailer.createTransport({
217
- ...smtp.transport,
218
- tls: {rejectUnauthorized: !allowInsecureTLS}
219
- });
220
- const info = await transport.sendMail({
221
- envelope: {
222
- // Bare, always. A display name here is not decoration the server
223
- // ignores - it is a MAIL FROM the server rejects, and `smtp.from`
224
- // is a free-text field somebody may well have typed a name into.
225
- from: bareAddress(smtp.from),
226
- to: [...addresses(draft.to), ...addresses(draft.cc), ...addresses(draft.bcc)]
227
- .map(a => a.address)
228
- },
229
- raw
230
- });
231
- transport.close();
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
- ...transportConfig,
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@ddb1e0c",
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.0",
168
+ "version": "1.2.1",
169
169
  "files": {
170
- "CLAUDE.md": "c4a8cedeff2af492871d2b9af446c6b73792881f4e428e189516105508bc07e5",
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": "5cf525d76b1f043a475268f8d37622bdb53120e0c34a1068930d7fcff88ea686",
186
+ "plugin.json": "c7ebf55a71048e914ca8e6d2f042fb099123367e1e6fd694cfbf9eb2e4146852",
187
187
  "server/common-passwords.NOTICE": "3b148a49a569bfa24feaa7879db602da8f0607fa61ab6c52b7a62391fc7c009a",
188
188
  "server/common-passwords.txt": "68782d6a4a19a4768d5f15dd66bd534e7a33055cc755411e33f16d18c50fdcce",
189
- "server/facts.js": "25538a8d1cfe012688fceb8ec0d0dd7b43ac9e168430723d6fb731183701fb46",
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. Cached 10 min; the sidebar badge (`/count`) = checks needing attention, red only
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.0",
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-15",
8
+ "date": "2026-10-16",
9
9
  "minCmsVersion": "0.83.0",
10
10
  "icon": "shield",
11
11
  "permissions": [