domma-cms 1.8.14 → 1.8.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +18 -1
- package/docs/configuration.md +1 -0
- package/package.json +1 -1
- package/plugins/_lib/mail/hostGuard.js +96 -12
- package/plugins/_lib/mail/imapPool.js +26 -4
- package/plugins/_lib/mail/poller.js +2 -1
- package/plugins/_lib/mail/readRoutes.js +4 -4
- package/plugins/_lib/mail/scheduler.js +7 -4
- package/plugins/_lib/mail/send.js +67 -18
- package/plugins/free-tier.lock.json +6 -5
- package/plugins/security/CLAUDE.md +9 -1
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/server/facts.js +105 -29
- package/plugins/security/tests/facts.test.js +109 -0
- package/server/routes/api/forms.js +13 -6
- package/server/routes/api/settings.js +22 -6
- package/server/services/actions.js +5 -2
- package/server/services/adapters/MongoAdapter.js +22 -6
- package/server/services/email.js +98 -8
- package/server/services/filterEngine.js +3 -1
- package/server/services/mailHost.js +100 -0
- package/server/services/mongoQueryGuard.js +193 -0
- package/server/services/notification-sources.js +2 -1
- package/server/services/outbound.js +394 -0
- package/server/services/views.js +13 -1
- package/server/services/wpImport/fetch.js +12 -181
|
@@ -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,
|
|
@@ -45,6 +45,7 @@ import {hooks} from '../../services/hooks.js';
|
|
|
45
45
|
import {saveMedia} from '../../services/content.js';
|
|
46
46
|
import {v4 as uuidv4} from 'uuid';
|
|
47
47
|
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
48
|
+
import {safeFetch} from '../../services/outbound.js';
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Detect form↔collection mismatches at save time so admins know BEFORE
|
|
@@ -961,11 +962,15 @@ export async function formsRoutes(fastify) {
|
|
|
961
962
|
const webhookAction = form.actions?.webhook;
|
|
962
963
|
if (record && webhookAction?.enabled && webhookAction.url) {
|
|
963
964
|
try {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
965
|
+
// services/outbound.js: public addresses only, pinned, no redirect inward.
|
|
966
|
+
const method = String(webhookAction.method || 'POST').toUpperCase();
|
|
967
|
+
const res = await safeFetch(webhookAction.url, {
|
|
968
|
+
method,
|
|
969
|
+
headers: { 'Content-Type': 'application/json' },
|
|
970
|
+
body: method === 'GET' || method === 'HEAD' ? undefined : JSON.stringify({ form: slug, data }),
|
|
971
|
+
truncate: true
|
|
968
972
|
});
|
|
973
|
+
if (!res.ok) throw new Error(`Webhook returned HTTP ${res.status}`);
|
|
969
974
|
} catch (err) {
|
|
970
975
|
fastify.log.warn(`[forms] Webhook failed for "${slug}": ${err.message}`);
|
|
971
976
|
formActionFailed(slug, form.title, 'webhook', err.message);
|
|
@@ -1058,7 +1063,9 @@ export async function formsRoutes(fastify) {
|
|
|
1058
1063
|
});
|
|
1059
1064
|
return { ok: true, message: `Test email sent to ${to}` };
|
|
1060
1065
|
} catch (err) {
|
|
1061
|
-
|
|
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 });
|
|
1062
1069
|
}
|
|
1063
1070
|
});
|
|
1064
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
|
|
|
@@ -203,10 +203,13 @@ async function executeStep(step, collection, entry, context) {
|
|
|
203
203
|
const body = cfg.body ? interpolateValue(cfg.body, context) : undefined;
|
|
204
204
|
const headers = { 'Content-Type': 'application/json', ...(cfg.headers || {}) };
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
// services/outbound.js: public addresses only, pinned, no redirect inward.
|
|
207
|
+
const { safeFetch } = await import('./outbound.js');
|
|
208
|
+
const res = await safeFetch(url, {
|
|
207
209
|
method,
|
|
208
210
|
headers,
|
|
209
|
-
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
211
|
+
body: body !== undefined && method !== 'GET' && method !== 'HEAD' ? JSON.stringify(body) : undefined,
|
|
212
|
+
truncate: true
|
|
210
213
|
});
|
|
211
214
|
|
|
212
215
|
if (!res.ok) throw new Error(`Webhook returned HTTP ${res.status}`);
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* count(slug) → number
|
|
20
20
|
* archive(slug) → string|null (set the entries aside after a storage move)
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
22
|
+
import { toSafeMongoQuery } from '../mongoQueryGuard.js';
|
|
23
23
|
|
|
24
24
|
/** Prefix applied to all CMS collection names in MongoDB to avoid collisions. */
|
|
25
25
|
const PREFIX = 'cms_';
|
|
@@ -36,6 +36,19 @@ function strip(doc) {
|
|
|
36
36
|
return rest;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* An entry id as it may be put in a query: text only. An object from a JSON
|
|
41
|
+
* body (`{"$ne": ""}`) would otherwise be read as an operator and pick
|
|
42
|
+
* whichever entry matched first.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} entryId
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function idOf(entryId) {
|
|
48
|
+
if (typeof entryId !== 'string' && typeof entryId !== 'number') throw Object.assign(new Error('Entry not found'), {statusCode: 404});
|
|
49
|
+
return String(entryId);
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
export class MongoAdapter {
|
|
40
53
|
|
|
41
54
|
/**
|
|
@@ -67,7 +80,7 @@ export class MongoAdapter {
|
|
|
67
80
|
* and structured filtering.
|
|
68
81
|
*
|
|
69
82
|
* `filter` is translated to native MongoDB query operators via
|
|
70
|
-
* `
|
|
83
|
+
* `mongoQueryGuard.toSafeMongoQuery()` and pushed down to the database - page
|
|
71
84
|
* authors writing `[collection where_salary_gte="50000"]` get index-aware
|
|
72
85
|
* queries automatically. When `search` is present, all documents are
|
|
73
86
|
* fetched and filtered in memory (same behaviour as FileAdapter, kept
|
|
@@ -88,7 +101,9 @@ export class MongoAdapter {
|
|
|
88
101
|
|
|
89
102
|
const sortField = sort === 'createdAt' ? 'meta.createdAt' : `data.${sort}`;
|
|
90
103
|
const sortDir = order === 'desc' ? -1 : 1;
|
|
91
|
-
|
|
104
|
+
// The one translation of a user-shaped filter for MongoDB - refuses
|
|
105
|
+
// anything that would run code on the server (mongoQueryGuard.js).
|
|
106
|
+
const query = toSafeMongoQuery(filter);
|
|
92
107
|
|
|
93
108
|
if (search) {
|
|
94
109
|
// Fetch matching (by filter) docs and substring-match in memory.
|
|
@@ -140,8 +155,9 @@ export class MongoAdapter {
|
|
|
140
155
|
* @returns {Promise<object|null>}
|
|
141
156
|
*/
|
|
142
157
|
async get(slug, entryId) {
|
|
158
|
+
if (typeof entryId !== 'string' && typeof entryId !== 'number') return null;
|
|
143
159
|
const col = await this._col(slug);
|
|
144
|
-
const doc = await col.findOne({ id: entryId }, { projection: { _id: 0 } });
|
|
160
|
+
const doc = await col.findOne({ id: idOf(entryId) }, { projection: { _id: 0 } });
|
|
145
161
|
return doc || null;
|
|
146
162
|
}
|
|
147
163
|
|
|
@@ -169,7 +185,7 @@ export class MongoAdapter {
|
|
|
169
185
|
*/
|
|
170
186
|
async update(slug, entryId, updated) {
|
|
171
187
|
const col = await this._col(slug);
|
|
172
|
-
const result = await col.replaceOne({ id: entryId }, { ...updated });
|
|
188
|
+
const result = await col.replaceOne({ id: idOf(entryId) }, { ...updated });
|
|
173
189
|
if (result.matchedCount === 0) throw new Error('Entry not found');
|
|
174
190
|
return updated;
|
|
175
191
|
}
|
|
@@ -184,7 +200,7 @@ export class MongoAdapter {
|
|
|
184
200
|
*/
|
|
185
201
|
async remove(slug, entryId) {
|
|
186
202
|
const col = await this._col(slug);
|
|
187
|
-
const result = await col.deleteOne({ id: entryId });
|
|
203
|
+
const result = await col.deleteOne({ id: idOf(entryId) });
|
|
188
204
|
if (result.deletedCount === 0) throw new Error('Entry not found');
|
|
189
205
|
}
|
|
190
206
|
|
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
|
}
|
|
@@ -262,7 +262,9 @@ export function toMongoQuery(filter) {
|
|
|
262
262
|
|
|
263
263
|
// Collapse single-operator sub-docs that only contain $eq back to a flat value.
|
|
264
264
|
for (const [path, sub] of Object.entries(query)) {
|
|
265
|
-
|
|
265
|
+
// Only a plain value: an object here would be read as operators, not matched as a value.
|
|
266
|
+
if (sub && typeof sub === 'object' && Object.keys(sub).length === 1 && '$eq' in sub
|
|
267
|
+
&& (sub.$eq === null || typeof sub.$eq !== 'object')) {
|
|
266
268
|
query[path] = sub.$eq;
|
|
267
269
|
}
|
|
268
270
|
}
|