domma-cms 1.8.14 → 1.8.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +18 -1
- package/docs/configuration.md +1 -0
- package/package.json +1 -1
- package/plugins/_lib/mail/hostGuard.js +96 -12
- package/plugins/_lib/mail/imapPool.js +26 -4
- package/plugins/_lib/mail/poller.js +2 -1
- package/plugins/_lib/mail/readRoutes.js +4 -4
- package/plugins/_lib/mail/scheduler.js +7 -4
- package/plugins/_lib/mail/send.js +67 -18
- package/plugins/free-tier.lock.json +6 -5
- package/plugins/security/CLAUDE.md +9 -1
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/server/facts.js +105 -29
- package/plugins/security/tests/facts.test.js +109 -0
- package/server/routes/api/forms.js +13 -6
- package/server/routes/api/settings.js +22 -6
- package/server/services/actions.js +5 -2
- package/server/services/adapters/MongoAdapter.js +22 -6
- package/server/services/email.js +98 -8
- package/server/services/filterEngine.js +3 -1
- package/server/services/mailHost.js +100 -0
- package/server/services/mongoQueryGuard.js +193 -0
- package/server/services/notification-sources.js +2 -1
- package/server/services/outbound.js +394 -0
- package/server/services/views.js +13 -1
- package/server/services/wpImport/fetch.js +12 -181
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound requests to addresses a site user chose - without becoming a way
|
|
3
|
+
* into the server's own network (SSRF).
|
|
4
|
+
*
|
|
5
|
+
* Every server-side request whose URL a site user can set goes through
|
|
6
|
+
* `safeFetch()` here: Actions' webhook step, a form's webhook, and any plugin
|
|
7
|
+
* that fetches a user's URL through core. The WordPress importer's media
|
|
8
|
+
* downloader (wpImport/fetch.js) uses the same address rules and pinned
|
|
9
|
+
* connection, with its own host list on top.
|
|
10
|
+
*
|
|
11
|
+
* - http(s) only, no user name or password in the address, ports 80 and 443.
|
|
12
|
+
* - The host name is resolved first, and EVERY address it resolves to must be
|
|
13
|
+
* public: loopback, private, link-local (cloud metadata lives at
|
|
14
|
+
* 169.254.169.254), CGNAT, multicast, reserved and documentation ranges are
|
|
15
|
+
* refused, IPv4-mapped and NAT64 IPv6 forms included - and so is every
|
|
16
|
+
* address of this server's own interfaces, public ones too. The WHATWG URL
|
|
17
|
+
* parser has already turned decimal, octal and hex IPv4 spellings
|
|
18
|
+
* (http://2130706433/, http://0177.0.0.1/) into dotted form, so they are
|
|
19
|
+
* checked as the address they are.
|
|
20
|
+
* - The connection goes to the address that was checked (a pinned lookup), so
|
|
21
|
+
* a second DNS answer cannot swap in an internal one (DNS rebinding).
|
|
22
|
+
* - Redirects are followed at most three times, each one checked as above.
|
|
23
|
+
* - A time limit for the whole request and a cap on the answer's size.
|
|
24
|
+
*
|
|
25
|
+
* Self-hosted sites that need internal targets (a webhook to n8n on the LAN)
|
|
26
|
+
* set `outboundAllowPrivate` in config/server.json: `true` lifts the address
|
|
27
|
+
* and port rules for every request, a list of host names, addresses and CIDR
|
|
28
|
+
* ranges lifts them for those only. It is IGNORED on a signed-only site
|
|
29
|
+
* (codePolicy.signedOnly() - every site a Manager hosts), because there the
|
|
30
|
+
* site's admin is a customer, not the owner of the network.
|
|
31
|
+
*
|
|
32
|
+
* @module services/outbound
|
|
33
|
+
*/
|
|
34
|
+
import dns from 'node:dns';
|
|
35
|
+
import http from 'node:http';
|
|
36
|
+
import https from 'node:https';
|
|
37
|
+
import net from 'node:net';
|
|
38
|
+
import os from 'node:os';
|
|
39
|
+
import {getConfig} from '../config.js';
|
|
40
|
+
import {signedOnly} from './codePolicy.js';
|
|
41
|
+
|
|
42
|
+
export const FETCH_PORTS = Object.freeze([80, 443]);
|
|
43
|
+
|
|
44
|
+
export const OUTBOUND_LIMITS = Object.freeze({maxBytes: 1024 * 1024, timeoutMs: 15_000, maxRedirects: 3});
|
|
45
|
+
|
|
46
|
+
export class FetchRefused extends Error {
|
|
47
|
+
constructor(message) { super(message); this.code = 'EREFUSED_SSRF'; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The port an address would be fetched on. */
|
|
51
|
+
export function portOf(raw) {
|
|
52
|
+
try { const u = new URL(raw); return u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80); } catch { return 0; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Addresses
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const V4_BLOCKED = [
|
|
60
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
|
|
61
|
+
['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
|
|
62
|
+
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function v4ToInt(ip) {
|
|
66
|
+
const p = String(ip).split('.').map(Number);
|
|
67
|
+
if (p.length !== 4 || p.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
68
|
+
return ((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function v4InRange(n, base, bits) {
|
|
72
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
73
|
+
return ((n & mask) >>> 0) === ((v4ToInt(base) & mask) >>> 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function v4Blocked(ip) {
|
|
77
|
+
const n = v4ToInt(ip);
|
|
78
|
+
if (n === null) return true;
|
|
79
|
+
return V4_BLOCKED.some(([base, bits]) => v4InRange(n, base, bits));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Expand an IPv6 address to 8 groups of numbers (null when it is not one). */
|
|
83
|
+
function v6Groups(ip) {
|
|
84
|
+
let s = String(ip).toLowerCase().split('%')[0];
|
|
85
|
+
// A dotted IPv4 tail (::ffff:1.2.3.4).
|
|
86
|
+
const tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
|
|
87
|
+
if (tail) {
|
|
88
|
+
const n = v4ToInt(tail[1]);
|
|
89
|
+
if (n === null) return null;
|
|
90
|
+
s = s.slice(0, -tail[1].length) + `${(n >>> 16).toString(16)}:${(n & 0xffff).toString(16)}`;
|
|
91
|
+
}
|
|
92
|
+
const halves = s.split('::');
|
|
93
|
+
if (halves.length > 2) return null;
|
|
94
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
95
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
96
|
+
const fill = halves.length === 2 ? 8 - left.length - right.length : 0;
|
|
97
|
+
const groups = [...left, ...Array(Math.max(0, fill)).fill('0'), ...right].map(g => Number.parseInt(g || '0', 16));
|
|
98
|
+
return groups.length === 8 && groups.every(g => Number.isInteger(g) && g >= 0 && g <= 0xffff) ? groups : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Is this address one a server-side fetch must never reach?
|
|
103
|
+
*
|
|
104
|
+
* @param {string} ip
|
|
105
|
+
* @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
export function isBlockedAddress(ip) {
|
|
108
|
+
const kind = net.isIP(String(ip || ''));
|
|
109
|
+
if (kind === 4) return v4Blocked(ip);
|
|
110
|
+
if (kind !== 6) return true;
|
|
111
|
+
const g = v6Groups(ip);
|
|
112
|
+
if (!g) return true;
|
|
113
|
+
const embeddedV4 = () => `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
|
|
114
|
+
if (g.every(x => x === 0)) return true; // ::
|
|
115
|
+
if (g.slice(0, 7).every(x => x === 0) && g[7] === 1) return true; // ::1
|
|
116
|
+
if (g.slice(0, 5).every(x => x === 0) && g[5] === 0xffff) return v4Blocked(embeddedV4()); // ::ffff:a.b.c.d
|
|
117
|
+
if (g.slice(0, 4).every(x => x === 0) && g[4] === 0xffff && g[5] === 0) return true; // ::ffff:0:0/96 (SIIT)
|
|
118
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g[2] === 1) return true; // 64:ff9b:1::/48 (local NAT64)
|
|
119
|
+
if (g[0] === 0x100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return true; // 100::/64 (discard)
|
|
120
|
+
if (g.slice(0, 6).every(x => x === 0)) return true; // ::a.b.c.d (deprecated compat)
|
|
121
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every(x => x === 0)) return v4Blocked(embeddedV4()); // NAT64
|
|
122
|
+
if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
|
|
123
|
+
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
|
124
|
+
if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local
|
|
125
|
+
if ((g[0] & 0xff00) === 0xff00) return true; // multicast
|
|
126
|
+
if (g[0] === 0x2001 && g[1] === 0x0db8) return true; // documentation
|
|
127
|
+
if (g[0] === 0x2002) return v4Blocked(`${g[1] >> 8}.${g[1] & 255}.${g[2] >> 8}.${g[2] & 255}`); // 6to4
|
|
128
|
+
if (g[0] === 0x2001 && g[1] === 0) return true; // Teredo
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Canonical text for an address, so the two spellings of one IPv6 address compare equal. */
|
|
133
|
+
export function canonAddress(ip) {
|
|
134
|
+
const s = String(ip || '').toLowerCase().split('%')[0];
|
|
135
|
+
if (net.isIP(s) !== 6) return s;
|
|
136
|
+
const g = v6Groups(s);
|
|
137
|
+
if (g && g.slice(0, 5).every(x => x === 0) && g[5] === 0xffff) return `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
|
|
138
|
+
return g ? g.map(x => x.toString(16)).join(':') : s;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* This machine's own addresses - every interface, public ones included, plus
|
|
143
|
+
* the unspecified ones. A server must never fetch from itself, whatever its
|
|
144
|
+
* public address resolves as.
|
|
145
|
+
*/
|
|
146
|
+
export function ownAddresses(interfaces = os.networkInterfaces()) {
|
|
147
|
+
const out = new Set(['0.0.0.0', canonAddress('::')]);
|
|
148
|
+
for (const list of Object.values(interfaces || {})) {
|
|
149
|
+
for (const a of list || []) if (a?.address) out.add(canonAddress(a.address));
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Does an address fall inside `a.b.c.d/n`, `v6::/n` or equal a plain address? */
|
|
155
|
+
function addressMatches(ip, entry) {
|
|
156
|
+
const [base, bitsRaw] = String(entry).split('/');
|
|
157
|
+
const a = canonAddress(ip);
|
|
158
|
+
const b = canonAddress(base);
|
|
159
|
+
if (bitsRaw === undefined) return a === b;
|
|
160
|
+
const bits = Number(bitsRaw);
|
|
161
|
+
if (net.isIP(a) === 4 && net.isIP(b) === 4) {
|
|
162
|
+
return Number.isInteger(bits) && bits >= 0 && bits <= 32 && v4InRange(v4ToInt(a), b, bits);
|
|
163
|
+
}
|
|
164
|
+
const ga = v6Groups(a);
|
|
165
|
+
const gb = v6Groups(b);
|
|
166
|
+
if (!ga || !gb || !Number.isInteger(bits) || bits < 0 || bits > 128) return false;
|
|
167
|
+
for (let i = 0, left = bits; left > 0; i++, left -= 16) {
|
|
168
|
+
const take = Math.min(16, left);
|
|
169
|
+
const mask = (0xffff << (16 - take)) & 0xffff;
|
|
170
|
+
if ((ga[i] & mask) !== (gb[i] & mask)) return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve a host and check every address.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} hostname
|
|
179
|
+
* @param {object} [opts]
|
|
180
|
+
* @param {boolean} [opts.allowPrivate] - skip the address checks entirely
|
|
181
|
+
* @param {(address: string) => boolean} [opts.allowAddress] - addresses the site allowlisted
|
|
182
|
+
* @param {Function} [opts.lookup] - dns.promises.lookup, swappable in tests
|
|
183
|
+
* @param {Set<string>} [opts.own] - this server's addresses
|
|
184
|
+
* @returns {Promise<{address: string, family: number, allowlisted: boolean}>} the address to connect to
|
|
185
|
+
*/
|
|
186
|
+
export async function resolvePublic(hostname, {allowPrivate = false, allowAddress = null, lookup = dns.promises.lookup, own = ownAddresses()} = {}) {
|
|
187
|
+
const host = String(hostname || '').replace(/^\[|\]$/g, '');
|
|
188
|
+
let addrs;
|
|
189
|
+
if (net.isIP(host)) addrs = [{address: host, family: net.isIP(host)}];
|
|
190
|
+
else {
|
|
191
|
+
try { addrs = await lookup(host, {all: true, verbatim: true}); } catch (err) { throw new FetchRefused(`${host} could not be found (${err.code || err.message}).`); }
|
|
192
|
+
}
|
|
193
|
+
if (!addrs?.length) throw new FetchRefused(`${host} could not be found.`);
|
|
194
|
+
const listed = (a) => !!allowAddress && allowAddress(a.address);
|
|
195
|
+
if (!allowPrivate) {
|
|
196
|
+
const bad = addrs.find(a => isBlockedAddress(a.address) && !listed(a));
|
|
197
|
+
if (bad) throw new FetchRefused(`${host} points at a private or reserved address (${bad.address}) - refused.`);
|
|
198
|
+
const self = addrs.find(a => own.has(canonAddress(a.address)) && !listed(a));
|
|
199
|
+
if (self) throw new FetchRefused(`${host} points at this server itself (${self.address}) - refused.`);
|
|
200
|
+
}
|
|
201
|
+
return {address: addrs[0].address, family: addrs[0].family || net.isIP(addrs[0].address), allowlisted: allowPrivate || addrs.every(listed)};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Scheme and credentials (before any DNS). Returns the URL, or throws. */
|
|
205
|
+
export function parseOutboundUrl(raw) {
|
|
206
|
+
let u;
|
|
207
|
+
try { u = new URL(String(raw || '').trim()); } catch { throw new FetchRefused('Not a web address.'); }
|
|
208
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new FetchRefused(`${u.protocol} addresses are not fetched.`);
|
|
209
|
+
if (u.username || u.password) throw new FetchRefused('Addresses with a user name or password are not fetched.');
|
|
210
|
+
return u;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// The site's allowlist (config/server.json outboundAllowPrivate)
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* What this site lets outbound requests reach beyond the public internet.
|
|
219
|
+
*
|
|
220
|
+
* @param {object} [opts]
|
|
221
|
+
* @param {object} [opts.server] - server.json (read fresh when omitted)
|
|
222
|
+
* @param {boolean} [opts.signed] - signed-only (codePolicy.signedOnly() when omitted)
|
|
223
|
+
* @returns {{all: boolean, hosts: string[], addresses: string[]}}
|
|
224
|
+
*/
|
|
225
|
+
export function outboundPolicy({server, signed} = {}) {
|
|
226
|
+
const none = {all: false, hosts: [], addresses: []};
|
|
227
|
+
if (signed ?? signedOnly()) return none;
|
|
228
|
+
let cfg = server;
|
|
229
|
+
if (cfg === undefined) { try { cfg = getConfig('server'); } catch { cfg = {}; } }
|
|
230
|
+
const v = cfg?.outboundAllowPrivate;
|
|
231
|
+
if (v === true) return {all: true, hosts: [], addresses: []};
|
|
232
|
+
if (!Array.isArray(v)) return none;
|
|
233
|
+
const entries = v.filter(x => typeof x === 'string' && x.trim()).map(x => x.trim().toLowerCase().replace(/^\[|\]$/g, ''));
|
|
234
|
+
return {
|
|
235
|
+
all: false,
|
|
236
|
+
addresses: entries.filter(x => net.isIP(x.split('/')[0])),
|
|
237
|
+
hosts: entries.filter(x => !net.isIP(x.split('/')[0]))
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// The request
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* One HTTP request to an address already checked, connecting only to that
|
|
247
|
+
* address. Never follows a redirect itself.
|
|
248
|
+
*
|
|
249
|
+
* @returns {Promise<{status: number, headers: object, buffer: Buffer, truncated: boolean, redirect?: string}>}
|
|
250
|
+
*/
|
|
251
|
+
export function pinnedRequest(u, {address, family, method = 'GET', headers = {}, body, deadline, maxBytes, signal, truncate = false}) {
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
254
|
+
const payload = body === undefined || body === null ? null : (Buffer.isBuffer(body) ? body : Buffer.from(String(body)));
|
|
255
|
+
if (payload && !Object.keys(headers).some(k => /^content-length$/i.test(k))) headers = {...headers, 'content-length': String(payload.length)};
|
|
256
|
+
let done = false;
|
|
257
|
+
const finish = (fn, v) => { if (!done) { done = true; clearTimeout(timer); fn(v); } };
|
|
258
|
+
const req = lib.request({
|
|
259
|
+
protocol: u.protocol,
|
|
260
|
+
hostname: u.hostname.replace(/^\[|\]$/g, ''),
|
|
261
|
+
servername: net.isIP(u.hostname.replace(/^\[|\]$/g, '')) ? undefined : u.hostname,
|
|
262
|
+
port: u.port || undefined,
|
|
263
|
+
path: `${u.pathname}${u.search}`,
|
|
264
|
+
method,
|
|
265
|
+
headers,
|
|
266
|
+
// Connect to the address that was checked - not to a fresh lookup.
|
|
267
|
+
lookup: (_host, options, cb) => {
|
|
268
|
+
if (options?.all) cb(null, [{address, family}]);
|
|
269
|
+
else cb(null, address, family);
|
|
270
|
+
},
|
|
271
|
+
agent: false,
|
|
272
|
+
signal
|
|
273
|
+
}, (res) => {
|
|
274
|
+
const status = res.statusCode || 0;
|
|
275
|
+
if (status >= 300 && status < 400 && res.headers.location) {
|
|
276
|
+
res.resume();
|
|
277
|
+
return finish(resolve, {status, headers: res.headers, buffer: Buffer.alloc(0), truncated: false, redirect: String(res.headers.location)});
|
|
278
|
+
}
|
|
279
|
+
const declared = Number(res.headers['content-length']);
|
|
280
|
+
if (!truncate && Number.isFinite(declared) && declared > maxBytes) {
|
|
281
|
+
res.destroy();
|
|
282
|
+
return finish(reject, new FetchRefused(`The answer is ${Math.round(declared / 1048576)} MB - over the ${Math.round(maxBytes / 1048576)} MB limit.`));
|
|
283
|
+
}
|
|
284
|
+
const chunks = [];
|
|
285
|
+
let size = 0;
|
|
286
|
+
res.on('data', (c) => {
|
|
287
|
+
if (size + c.length > maxBytes) {
|
|
288
|
+
res.destroy();
|
|
289
|
+
if (truncate) {
|
|
290
|
+
chunks.push(c.subarray(0, maxBytes - size));
|
|
291
|
+
finish(resolve, {status, headers: res.headers, buffer: Buffer.concat(chunks), truncated: true});
|
|
292
|
+
} else {
|
|
293
|
+
finish(reject, new FetchRefused(`The answer is over the ${Math.round(maxBytes / 1048576)} MB limit.`));
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
size += c.length;
|
|
298
|
+
chunks.push(c);
|
|
299
|
+
});
|
|
300
|
+
res.on('end', () => finish(resolve, {status, headers: res.headers, buffer: Buffer.concat(chunks), truncated: false}));
|
|
301
|
+
res.on('error', (err) => finish(reject, err));
|
|
302
|
+
});
|
|
303
|
+
const timer = setTimeout(() => { req.destroy(); finish(reject, Object.assign(new Error('The request took too long.'), {code: 'ETIMEDOUT'})); }, Math.max(1, deadline - Date.now()));
|
|
304
|
+
req.on('error', (err) => finish(reject, err));
|
|
305
|
+
if (payload) req.write(payload);
|
|
306
|
+
req.end();
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Check one URL against the rules and the site's allowlist, and resolve it.
|
|
312
|
+
*
|
|
313
|
+
* @returns {Promise<{u: URL, address: string, family: number}>}
|
|
314
|
+
*/
|
|
315
|
+
export async function checkOutbound(raw, {policy = outboundPolicy(), lookup, own, allowPrivate = false} = {}) {
|
|
316
|
+
const u = parseOutboundUrl(raw);
|
|
317
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
318
|
+
const hostListed = policy.all || allowPrivate || policy.hosts.includes(host);
|
|
319
|
+
const allowAddress = policy.addresses.length ? (ip) => policy.addresses.some(e => addressMatches(ip, e)) : null;
|
|
320
|
+
const {address, family, allowlisted} = await resolvePublic(host, {
|
|
321
|
+
allowPrivate: hostListed, allowAddress, ...(lookup && {lookup}), ...(own && {own})
|
|
322
|
+
});
|
|
323
|
+
const port = portOf(u.href);
|
|
324
|
+
if (!allowlisted && !FETCH_PORTS.includes(port)) throw new FetchRefused(`Port ${port} is not reached - only 80 and 443.`);
|
|
325
|
+
return {u, address, family};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Make a request to a URL a site user chose.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} url
|
|
332
|
+
* @param {object} [opts]
|
|
333
|
+
* @param {string} [opts.method='GET']
|
|
334
|
+
* @param {object} [opts.headers]
|
|
335
|
+
* @param {string|Buffer} [opts.body]
|
|
336
|
+
* @param {number} [opts.timeoutMs]
|
|
337
|
+
* @param {number} [opts.maxBytes] - the answer's size cap
|
|
338
|
+
* @param {boolean} [opts.truncate] - cut an oversized answer instead of failing
|
|
339
|
+
* @param {number} [opts.maxRedirects]
|
|
340
|
+
* @param {AbortSignal} [opts.signal]
|
|
341
|
+
* @param {object} [opts.policy] - outboundPolicy(), read fresh when omitted
|
|
342
|
+
* @param {Function} [opts.lookup] - tests
|
|
343
|
+
* @param {Set<string>} [opts.own] - tests
|
|
344
|
+
* @returns {Promise<{ok: boolean, status: number, headers: object, buffer: Buffer, text: () => string, json: () => unknown, url: string, truncated: boolean}>}
|
|
345
|
+
*/
|
|
346
|
+
export async function safeFetch(url, opts = {}) {
|
|
347
|
+
const lim = {...OUTBOUND_LIMITS, ...Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== undefined))};
|
|
348
|
+
const deadline = Date.now() + lim.timeoutMs;
|
|
349
|
+
const policy = opts.policy || outboundPolicy();
|
|
350
|
+
let method = String(opts.method || 'GET').toUpperCase();
|
|
351
|
+
let body = opts.body;
|
|
352
|
+
// Headers that steer the connection itself are the request's, not the caller's.
|
|
353
|
+
const callerHeaders = Object.fromEntries(Object.entries(opts.headers || {})
|
|
354
|
+
.filter(([k]) => !/^(host|connection|transfer-encoding|content-length|keep-alive|upgrade|te|trailer|proxy-.*)$/i.test(k)));
|
|
355
|
+
let headers = {'user-agent': 'DommaCMS/1', ...callerHeaders};
|
|
356
|
+
let current = url;
|
|
357
|
+
let first = null;
|
|
358
|
+
for (let hop = 0; hop <= lim.maxRedirects; hop++) {
|
|
359
|
+
const {u, address, family} = await checkOutbound(current, {policy, lookup: opts.lookup, own: opts.own});
|
|
360
|
+
if (first === null) first = u;
|
|
361
|
+
else if (u.origin !== first.origin) {
|
|
362
|
+
// A redirect to another origin (or from https down to http) takes none of
|
|
363
|
+
// the caller's headers - an API key meant for the first host stays there.
|
|
364
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([k]) => /^(user-agent|content-type|accept)$/i.test(k)));
|
|
365
|
+
}
|
|
366
|
+
const res = await pinnedRequest(u, {
|
|
367
|
+
address, family, method, headers, body, deadline,
|
|
368
|
+
maxBytes: lim.maxBytes, truncate: !!opts.truncate, signal: opts.signal
|
|
369
|
+
});
|
|
370
|
+
if (res.redirect) {
|
|
371
|
+
if (hop === lim.maxRedirects) throw new FetchRefused('Too many redirects.');
|
|
372
|
+
current = new URL(res.redirect, u).href;
|
|
373
|
+
// As browsers do: 303, and 301/302 after a POST, continue as a GET without the body.
|
|
374
|
+
if (res.status === 303 || ((res.status === 301 || res.status === 302) && method === 'POST')) {
|
|
375
|
+
method = 'GET';
|
|
376
|
+
body = undefined;
|
|
377
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([k]) => !/^content-(type|length)$/i.test(k)));
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const buffer = res.buffer;
|
|
382
|
+
return {
|
|
383
|
+
ok: res.status >= 200 && res.status < 300,
|
|
384
|
+
status: res.status,
|
|
385
|
+
headers: res.headers,
|
|
386
|
+
buffer,
|
|
387
|
+
truncated: res.truncated,
|
|
388
|
+
url: u.href,
|
|
389
|
+
text: () => buffer.toString('utf8'),
|
|
390
|
+
json: () => JSON.parse(buffer.toString('utf8'))
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
throw new FetchRefused('Too many redirects.');
|
|
394
|
+
}
|
package/server/services/views.js
CHANGED
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Forbidden stage types:
|
|
26
26
|
* $out, $merge, $function, $accumulator, $graphLookup
|
|
27
|
+
*
|
|
28
|
+
* Stage CONFIGS are checked too, at any depth (mongoQueryGuard.js): no
|
|
29
|
+
* `$where`/`$function`/`$accumulator` hidden in a `$match`, `$group` or
|
|
30
|
+
* `$lookup` sub-pipeline, and a `$lookup` only into this site's `cms_<slug>`
|
|
31
|
+
* collections. Checked on save and again before MongoDB runs the pipeline.
|
|
27
32
|
*/
|
|
28
33
|
import fs from 'fs/promises';
|
|
29
34
|
import path from 'path';
|
|
@@ -35,6 +40,7 @@ import {getEffectiveLevel} from './userRoles.js';
|
|
|
35
40
|
import {executePipeline} from './viewPipeline.js';
|
|
36
41
|
import * as cache from './cache/index.js';
|
|
37
42
|
import {fileIn} from './safePath.js';
|
|
43
|
+
import {assertSafeMongoQuery, toSafeMongoPipeline} from './mongoQueryGuard.js';
|
|
38
44
|
|
|
39
45
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
40
46
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -88,6 +94,7 @@ function validateStages(stages) {
|
|
|
88
94
|
if (!ALLOWED_STAGES.has(stage.type)) {
|
|
89
95
|
throw new Error(`Stage type "${stage.type}" is not in the allowed list`);
|
|
90
96
|
}
|
|
97
|
+
assertSafeMongoQuery({[stage.type]: stage.config});
|
|
91
98
|
}
|
|
92
99
|
}
|
|
93
100
|
|
|
@@ -389,7 +396,12 @@ async function executeMongoView(view, { page, limit, user }) {
|
|
|
389
396
|
const db = getDb(view.connection || 'default');
|
|
390
397
|
|
|
391
398
|
const sourceCollection = `${COLLECTION_PREFIX}${view.pipeline.source}`;
|
|
392
|
-
|
|
399
|
+
// The one translation of a view for MongoDB: refuses code-running
|
|
400
|
+
// operators at any depth and a $lookup outside this site's collections.
|
|
401
|
+
const {getCollection} = await import('./collections.js');
|
|
402
|
+
const pipeline = await toSafeMongoPipeline(view.pipeline?.stages || [], {
|
|
403
|
+
collectionExists: async (slug) => !!(await getCollection(slug).catch(() => null))
|
|
404
|
+
});
|
|
393
405
|
|
|
394
406
|
// Prepend row-level $match if configured
|
|
395
407
|
const rowLevel = view.access?.rowLevel;
|
|
@@ -21,25 +21,15 @@
|
|
|
21
21
|
*
|
|
22
22
|
* @module services/wpImport/fetch
|
|
23
23
|
*/
|
|
24
|
-
import
|
|
25
|
-
import http from 'node:http';
|
|
26
|
-
import https from 'node:https';
|
|
27
|
-
import net from 'node:net';
|
|
28
|
-
import os from 'node:os';
|
|
24
|
+
import {FETCH_PORTS, FetchRefused, isBlockedAddress, ownAddresses, pinnedRequest, portOf, resolvePublic} from '../outbound.js';
|
|
29
25
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
export
|
|
34
|
-
try { const u = new URL(raw); return u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80); } catch { return 0; }
|
|
35
|
-
}
|
|
26
|
+
// The address rules and the pinned connection are shared with every other
|
|
27
|
+
// outbound request (services/outbound.js); this module adds the WordPress
|
|
28
|
+
// site's own host list, the 50 MB cap and "only a 200 is a file".
|
|
29
|
+
export {FETCH_PORTS, FetchRefused, isBlockedAddress, ownAddresses, portOf, resolvePublic};
|
|
36
30
|
|
|
37
31
|
export const FETCH_LIMITS = Object.freeze({maxBytes: 50 * 1024 * 1024, timeoutMs: 60_000, maxRedirects: 3});
|
|
38
32
|
|
|
39
|
-
export class FetchRefused extends Error {
|
|
40
|
-
constructor(message) { super(message); this.code = 'EREFUSED_SSRF'; }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
33
|
/**
|
|
44
34
|
* The test-only switch: private addresses are allowed only outside
|
|
45
35
|
* production AND with WP_IMPORT_ALLOW_PRIVATE_HOSTS=1 set.
|
|
@@ -48,121 +38,6 @@ export function testHostsAllowed(env = process.env) {
|
|
|
48
38
|
return env.NODE_ENV !== 'production' && env.WP_IMPORT_ALLOW_PRIVATE_HOSTS === '1';
|
|
49
39
|
}
|
|
50
40
|
|
|
51
|
-
const V4_BLOCKED = [
|
|
52
|
-
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
|
|
53
|
-
['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
|
|
54
|
-
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]
|
|
55
|
-
];
|
|
56
|
-
|
|
57
|
-
function v4ToInt(ip) {
|
|
58
|
-
const p = ip.split('.').map(Number);
|
|
59
|
-
if (p.length !== 4 || p.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
60
|
-
return ((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function v4Blocked(ip) {
|
|
64
|
-
const n = v4ToInt(ip);
|
|
65
|
-
if (n === null) return true;
|
|
66
|
-
return V4_BLOCKED.some(([base, bits]) => {
|
|
67
|
-
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
68
|
-
return ((n & mask) >>> 0) === ((v4ToInt(base) & mask) >>> 0);
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Expand an IPv6 address to 8 groups of numbers (null when it is not one). */
|
|
73
|
-
function v6Groups(ip) {
|
|
74
|
-
let s = ip.toLowerCase().split('%')[0];
|
|
75
|
-
// A dotted IPv4 tail (::ffff:1.2.3.4).
|
|
76
|
-
const tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
|
|
77
|
-
if (tail) {
|
|
78
|
-
const n = v4ToInt(tail[1]);
|
|
79
|
-
if (n === null) return null;
|
|
80
|
-
s = s.slice(0, -tail[1].length) + `${(n >>> 16).toString(16)}:${(n & 0xffff).toString(16)}`;
|
|
81
|
-
}
|
|
82
|
-
const halves = s.split('::');
|
|
83
|
-
if (halves.length > 2) return null;
|
|
84
|
-
const left = halves[0] ? halves[0].split(':') : [];
|
|
85
|
-
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
86
|
-
const fill = halves.length === 2 ? 8 - left.length - right.length : 0;
|
|
87
|
-
const groups = [...left, ...Array(Math.max(0, fill)).fill('0'), ...right].map(g => Number.parseInt(g || '0', 16));
|
|
88
|
-
return groups.length === 8 && groups.every(g => Number.isInteger(g) && g >= 0 && g <= 0xffff) ? groups : null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Is this address one a server-side fetch must never reach?
|
|
93
|
-
*
|
|
94
|
-
* @param {string} ip
|
|
95
|
-
* @returns {boolean}
|
|
96
|
-
*/
|
|
97
|
-
export function isBlockedAddress(ip) {
|
|
98
|
-
const kind = net.isIP(String(ip || ''));
|
|
99
|
-
if (kind === 4) return v4Blocked(ip);
|
|
100
|
-
if (kind !== 6) return true;
|
|
101
|
-
const g = v6Groups(ip);
|
|
102
|
-
if (!g) return true;
|
|
103
|
-
const embeddedV4 = () => `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
|
|
104
|
-
if (g.every(x => x === 0)) return true; // ::
|
|
105
|
-
if (g.slice(0, 7).every(x => x === 0) && g[7] === 1) return true; // ::1
|
|
106
|
-
if (g.slice(0, 5).every(x => x === 0) && g[5] === 0xffff) return v4Blocked(embeddedV4()); // ::ffff:a.b.c.d
|
|
107
|
-
if (g.slice(0, 4).every(x => x === 0) && g[4] === 0xffff && g[5] === 0) return true; // ::ffff:0:0/96 (SIIT)
|
|
108
|
-
if (g[0] === 0x64 && g[1] === 0xff9b && g[2] === 1) return true; // 64:ff9b:1::/48 (local NAT64)
|
|
109
|
-
if (g[0] === 0x100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return true; // 100::/64 (discard)
|
|
110
|
-
if (g.slice(0, 6).every(x => x === 0)) return true; // ::a.b.c.d (deprecated compat)
|
|
111
|
-
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every(x => x === 0)) return v4Blocked(embeddedV4()); // NAT64
|
|
112
|
-
if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
|
|
113
|
-
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
|
114
|
-
if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local
|
|
115
|
-
if ((g[0] & 0xff00) === 0xff00) return true; // multicast
|
|
116
|
-
if (g[0] === 0x2001 && g[1] === 0x0db8) return true; // documentation
|
|
117
|
-
if (g[0] === 0x2002) return v4Blocked(`${g[1] >> 8}.${g[1] & 255}.${g[2] >> 8}.${g[2] & 255}`); // 6to4
|
|
118
|
-
if (g[0] === 0x2001 && g[1] === 0) return true; // Teredo
|
|
119
|
-
return false;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Canonical text for an address, so the two spellings of one IPv6 address compare equal. */
|
|
123
|
-
function canon(ip) {
|
|
124
|
-
const s = String(ip || '').toLowerCase().split('%')[0];
|
|
125
|
-
if (net.isIP(s) !== 6) return s;
|
|
126
|
-
const g = v6Groups(s);
|
|
127
|
-
if (g && g.slice(0, 5).every(x => x === 0) && g[5] === 0xffff) return `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
|
|
128
|
-
return g ? g.map(x => x.toString(16)).join(':') : s;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* This machine's own addresses - every interface, public ones included, plus
|
|
133
|
-
* the unspecified ones. A server must never fetch from itself, whatever its
|
|
134
|
-
* public address resolves as.
|
|
135
|
-
*/
|
|
136
|
-
export function ownAddresses(interfaces = os.networkInterfaces()) {
|
|
137
|
-
const out = new Set(['0.0.0.0', canon('::')]);
|
|
138
|
-
for (const list of Object.values(interfaces || {})) {
|
|
139
|
-
for (const a of list || []) if (a?.address) out.add(canon(a.address));
|
|
140
|
-
}
|
|
141
|
-
return out;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Resolve a host and check every address.
|
|
146
|
-
*
|
|
147
|
-
* @returns {Promise<{address: string, family: number}>} the address to connect to
|
|
148
|
-
*/
|
|
149
|
-
export async function resolvePublic(hostname, {allowPrivate = false, lookup = dns.promises.lookup, own = ownAddresses()} = {}) {
|
|
150
|
-
const host = String(hostname || '').replace(/^\[|\]$/g, '');
|
|
151
|
-
let addrs;
|
|
152
|
-
if (net.isIP(host)) addrs = [{address: host, family: net.isIP(host)}];
|
|
153
|
-
else {
|
|
154
|
-
try { addrs = await lookup(host, {all: true, verbatim: true}); } catch (err) { throw new FetchRefused(`${host} could not be found (${err.code || err.message}).`); }
|
|
155
|
-
}
|
|
156
|
-
if (!addrs?.length) throw new FetchRefused(`${host} could not be found.`);
|
|
157
|
-
if (!allowPrivate) {
|
|
158
|
-
const bad = addrs.find(a => isBlockedAddress(a.address));
|
|
159
|
-
if (bad) throw new FetchRefused(`${host} points at a private or reserved address (${bad.address}) - refused.`);
|
|
160
|
-
const self = addrs.find(a => own.has(canon(a.address)));
|
|
161
|
-
if (self) throw new FetchRefused(`${host} points at this server itself (${self.address}) - refused.`);
|
|
162
|
-
}
|
|
163
|
-
return addrs[0];
|
|
164
|
-
}
|
|
165
|
-
|
|
166
41
|
/** May this URL be fetched at all (before any DNS)? Returns the URL, or throws. */
|
|
167
42
|
export function checkUrl(raw, {hosts = [], allowPrivate = false} = {}) {
|
|
168
43
|
let u;
|
|
@@ -209,56 +84,12 @@ export async function fetchMedia(url, opts = {}) {
|
|
|
209
84
|
throw new FetchRefused('Too many redirects.');
|
|
210
85
|
}
|
|
211
86
|
|
|
212
|
-
function request(u, {address, family, deadline, maxBytes, signal}) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const finish = (fn, v) => { if (!done) { done = true; clearTimeout(timer); fn(v); } };
|
|
217
|
-
const req = lib.request({
|
|
218
|
-
protocol: u.protocol,
|
|
219
|
-
hostname: u.hostname,
|
|
220
|
-
port: u.port || undefined,
|
|
221
|
-
path: `${u.pathname}${u.search}`,
|
|
222
|
-
method: 'GET',
|
|
223
|
-
headers: {'user-agent': 'DommaCMS-WordPress-Import/1', accept: '*/*'},
|
|
224
|
-
// Connect to the address that was checked - not to a fresh lookup.
|
|
225
|
-
lookup: (_host, options, cb) => {
|
|
226
|
-
if (options?.all) cb(null, [{address, family}]);
|
|
227
|
-
else cb(null, address, family);
|
|
228
|
-
},
|
|
229
|
-
agent: false,
|
|
230
|
-
signal
|
|
231
|
-
}, (res) => {
|
|
232
|
-
const status = res.statusCode || 0;
|
|
233
|
-
if (status >= 300 && status < 400 && res.headers.location) {
|
|
234
|
-
res.resume();
|
|
235
|
-
return finish(resolve, {redirect: String(res.headers.location)});
|
|
236
|
-
}
|
|
237
|
-
if (status !== 200) {
|
|
238
|
-
res.resume();
|
|
239
|
-
return finish(reject, Object.assign(new Error(`The old site answered ${status}.`), {code: 'EHTTP', status}));
|
|
240
|
-
}
|
|
241
|
-
const declared = Number(res.headers['content-length']);
|
|
242
|
-
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
243
|
-
res.destroy();
|
|
244
|
-
return finish(reject, new FetchRefused(`The file is ${Math.round(declared / 1048576)} MB - over the ${Math.round(maxBytes / 1048576)} MB limit.`));
|
|
245
|
-
}
|
|
246
|
-
const chunks = [];
|
|
247
|
-
let size = 0;
|
|
248
|
-
res.on('data', (c) => {
|
|
249
|
-
size += c.length;
|
|
250
|
-
if (size > maxBytes) {
|
|
251
|
-
res.destroy();
|
|
252
|
-
finish(reject, new FetchRefused(`The file is over the ${Math.round(maxBytes / 1048576)} MB limit.`));
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
chunks.push(c);
|
|
256
|
-
});
|
|
257
|
-
res.on('end', () => finish(resolve, {buffer: Buffer.concat(chunks), contentType: String(res.headers['content-type'] || '')}));
|
|
258
|
-
res.on('error', (err) => finish(reject, err));
|
|
259
|
-
});
|
|
260
|
-
const timer = setTimeout(() => { req.destroy(); finish(reject, Object.assign(new Error('The download took too long.'), {code: 'ETIMEDOUT'})); }, Math.max(1, deadline - Date.now()));
|
|
261
|
-
req.on('error', (err) => finish(reject, err));
|
|
262
|
-
req.end();
|
|
87
|
+
async function request(u, {address, family, deadline, maxBytes, signal}) {
|
|
88
|
+
const res = await pinnedRequest(u, {
|
|
89
|
+
address, family, deadline, maxBytes, signal,
|
|
90
|
+
headers: {'user-agent': 'DommaCMS-WordPress-Import/1', accept: '*/*'}
|
|
263
91
|
});
|
|
92
|
+
if (res.redirect) return {redirect: res.redirect};
|
|
93
|
+
if (res.status !== 200) throw Object.assign(new Error(`The old site answered ${res.status}.`), {code: 'EHTTP', status: res.status});
|
|
94
|
+
return {buffer: res.buffer, contentType: String(res.headers['content-type'] || '')};
|
|
264
95
|
}
|