domma-cms 1.8.13 → 1.8.15

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +29 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/views/plugin-code.js +3 -3
  5. package/admin/js/views/plugins.js +8 -8
  6. package/docs/DommaFeatures.md +2 -1
  7. package/docs/configuration.md +2 -1
  8. package/package.json +1 -1
  9. package/server/config.js +3 -2
  10. package/server/env.js +12 -0
  11. package/server/routes/api/collections.js +9 -0
  12. package/server/routes/api/forms.js +9 -4
  13. package/server/routes/api/plugin-marketplace.js +1 -2
  14. package/server/routes/api/plugins.js +25 -3
  15. package/server/server.js +1 -1
  16. package/server/services/actions.js +5 -2
  17. package/server/services/adapters/FileAdapter.js +2 -1
  18. package/server/services/adapters/MongoAdapter.js +22 -6
  19. package/server/services/codePolicy.js +111 -0
  20. package/server/services/collections.js +2 -1
  21. package/server/services/components.js +3 -2
  22. package/server/services/connectionManager.js +8 -0
  23. package/server/services/content.js +1 -1
  24. package/server/services/contextMenus.js +2 -1
  25. package/server/services/filterEngine.js +3 -1
  26. package/server/services/forms.js +4 -3
  27. package/server/services/menus.js +2 -1
  28. package/server/services/mongoQueryGuard.js +193 -0
  29. package/server/services/outbound.js +394 -0
  30. package/server/services/pluginFiles.js +8 -1
  31. package/server/services/pluginInstaller.js +16 -13
  32. package/server/services/pluginScaffold.js +3 -0
  33. package/server/services/safePath.js +35 -0
  34. package/server/services/staffAccess.js +2 -1
  35. package/server/services/versions.js +1 -1
  36. package/server/services/views.js +17 -4
  37. package/server/services/wpImport/fetch.js +12 -181
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The one gate between a user-shaped query and MongoDB.
3
+ *
4
+ * Site users shape queries in many places - saved views and the view editor,
5
+ * the `[view]` and `[collection]` shortcodes, collection filters, `/api/v1`
6
+ * query parameters, API Builder endpoints. Every one of them reaches MongoDB
7
+ * through one of two translations, and both go through this module:
8
+ *
9
+ * - a structured filter -> `toSafeMongoQuery()` (MongoAdapter.list)
10
+ * - a view pipeline -> `toSafeMongoPipeline()` (views.executeMongoView)
11
+ *
12
+ * Refused, at any depth (inside nested objects and arrays, `$lookup`
13
+ * sub-pipelines, `$expr`, `$group`...):
14
+ *
15
+ * - anything that runs JavaScript inside the database server: `$where`,
16
+ * `$function`, `$accumulator`, `$code`, and BSON Code values;
17
+ * - anything that writes, or reads outside the site's own collections:
18
+ * `$out`, `$merge`, `$unionWith`, `$graphLookup`, the server-state stages
19
+ * (`$currentOp`, `$collStats`, ...), and a `$lookup` that is not into one
20
+ * of this site's `cms_<slug>` collections (no cross-database form);
21
+ * - keys named `__proto__`, `constructor` or `prototype`.
22
+ *
23
+ * Plain operators (`$eq`, `$gt`, `$in`, `$regex`, `$expr` with ordinary
24
+ * expressions, `$group` with `$sum`...) pass untouched.
25
+ *
26
+ * @module services/mongoQueryGuard
27
+ */
28
+ import {toMongoQuery} from './filterEngine.js';
29
+
30
+ /** Operators that execute code on the database server. */
31
+ export const CODE_OPERATORS = Object.freeze(['$where', '$function', '$accumulator', '$code', '$mapReduce']);
32
+
33
+ /** Operators that write, or reach data or server state beyond the site's collections. */
34
+ export const REACH_OPERATORS = Object.freeze([
35
+ '$out', '$merge', '$unionWith', '$graphLookup', '$documents',
36
+ '$currentOp', '$listSessions', '$listLocalSessions', '$collStats', '$indexStats',
37
+ '$planCacheStats', '$changeStream', '$listSearchIndexes', '$listSampledQueries', '$querySettings'
38
+ ]);
39
+
40
+ const PROTOTYPE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
41
+ const REFUSED = new Set([...CODE_OPERATORS, ...REACH_OPERATORS].map(k => k.toLowerCase()));
42
+ const SITE_SLUG = /^[a-z0-9][a-z0-9_-]*$/;
43
+ const MAX_DEPTH = 64;
44
+
45
+ export class UnsafeQueryError extends Error {
46
+ constructor(message) {
47
+ super(message);
48
+ this.code = 'EUNSAFE_QUERY';
49
+ this.statusCode = 400;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Throw when a query, pipeline or any part of one could run code or reach
55
+ * outside the site's own collections.
56
+ *
57
+ * @param {unknown} value
58
+ * @param {object} [opts]
59
+ * @param {Set<string>|string[]} [opts.collections] - the site's collection slugs; when given, a `$lookup` must name one
60
+ * @param {string} [opts.prefix='cms_'] - the MongoDB name prefix of a site collection
61
+ * @returns {unknown} the same value, for chaining
62
+ */
63
+ export function assertSafeMongoQuery(value, opts = {}) {
64
+ const known = opts.collections ? new Set(opts.collections) : null;
65
+ const prefix = opts.prefix ?? 'cms_';
66
+ const seen = new Set();
67
+
68
+ const walk = (node, at, depth) => {
69
+ if (node === null || typeof node !== 'object') {
70
+ if (typeof node === 'function') throw new UnsafeQueryError(`${at || 'The query'} is a function - not allowed in a query.`);
71
+ return;
72
+ }
73
+ if (depth > MAX_DEPTH) throw new UnsafeQueryError('The query is nested too deeply.');
74
+ if (seen.has(node)) return;
75
+ seen.add(node);
76
+ // A BSON Code value runs as JavaScript wherever the server accepts one.
77
+ if (String(node._bsontype || '').toLowerCase().startsWith('code')) {
78
+ throw new UnsafeQueryError(`${at || 'The query'} holds JavaScript code - not allowed in a query.`);
79
+ }
80
+ if (Array.isArray(node)) {
81
+ node.forEach((item, i) => walk(item, `${at}[${i}]`, depth + 1));
82
+ return;
83
+ }
84
+ // Own keys, including a `__proto__` key that JSON.parse makes an own property.
85
+ for (const key of Reflect.ownKeys(node)) {
86
+ if (typeof key !== 'string') continue;
87
+ const here = at ? `${at}.${key}` : key;
88
+ const lower = key.toLowerCase();
89
+ // A dotted path ('data.x.__proto__', 'a.$where') is checked a part at a time.
90
+ for (const part of key.split('.')) {
91
+ if (PROTOTYPE_KEYS.has(part)) throw new UnsafeQueryError(`"${part}" is not allowed as a name in a query (at ${here}).`);
92
+ const p = part.toLowerCase();
93
+ if (REFUSED.has(p)) {
94
+ throw new UnsafeQueryError(`${part} is not allowed in a query - it ${CODE_OPERATORS.some(k => k.toLowerCase() === p) ? 'runs code on the database server' : 'reaches beyond this site\'s own collections'} (at ${here}).`);
95
+ }
96
+ }
97
+ const child = node[key];
98
+ if (lower === '$lookup') checkLookup(child, here, known, prefix);
99
+ walk(child, here, depth + 1);
100
+ }
101
+ };
102
+
103
+ walk(value, '', 0);
104
+ return value;
105
+ }
106
+
107
+ function checkLookup(config, at, known, prefix) {
108
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new UnsafeQueryError(`$lookup needs an object (at ${at}).`);
109
+ const from = config.from;
110
+ if (typeof from !== 'string') {
111
+ throw new UnsafeQueryError(`$lookup may only join one of this site's collections, named as text - not another database (at ${at}.from).`);
112
+ }
113
+ // `cms_<slug>` or the bare `<slug>` (the file evaluator accepts both; the
114
+ // MongoDB translation prefixes a bare one), never `cms__` meta stores.
115
+ const slug = from.startsWith(prefix) ? from.slice(prefix.length) : from;
116
+ if (!SITE_SLUG.test(slug)) {
117
+ throw new UnsafeQueryError(`$lookup may only join one of this site's collections - "${from}" is not one (at ${at}.from).`);
118
+ }
119
+ if (known && !known.has(slug)) {
120
+ throw new UnsafeQueryError(`$lookup names "${from}", which is not a collection on this site (at ${at}.from).`);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * A structured filter (filterEngine DSL) as a MongoDB query - refused when it
126
+ * would carry anything `assertSafeMongoQuery` refuses.
127
+ *
128
+ * @param {Record<string, unknown>|undefined} filter
129
+ * @returns {object}
130
+ */
131
+ export function toSafeMongoQuery(filter) {
132
+ if (!filter) return {};
133
+ assertSafeMongoQuery(filter);
134
+ return assertSafeMongoQuery(toMongoQuery(filter));
135
+ }
136
+
137
+ /**
138
+ * Every `$lookup` target named in a (checked) pipeline, at any depth.
139
+ *
140
+ * @param {unknown} value
141
+ * @returns {string[]} the `from` collection names
142
+ */
143
+ export function lookupTargets(value) {
144
+ const out = new Set();
145
+ const walk = (node) => {
146
+ if (!node || typeof node !== 'object') return;
147
+ if (Array.isArray(node)) return node.forEach(walk);
148
+ for (const [k, v] of Object.entries(node)) {
149
+ if (k === '$lookup' && typeof v?.from === 'string') out.add(v.from);
150
+ walk(v);
151
+ }
152
+ };
153
+ walk(value);
154
+ return [...out];
155
+ }
156
+
157
+ /**
158
+ * Stage descriptors (`{type, config}`) as a MongoDB aggregation pipeline,
159
+ * refused when any stage would carry anything `assertSafeMongoQuery` refuses.
160
+ *
161
+ * @param {object[]} stages
162
+ * @param {object} [opts]
163
+ * @param {Set<string>|string[]} [opts.collections] - the site's collection slugs
164
+ * @param {(slug: string) => Promise<boolean>} [opts.collectionExists] - asked for each `$lookup` target
165
+ * @returns {Promise<object[]>}
166
+ */
167
+ export async function toSafeMongoPipeline(stages, opts = {}) {
168
+ const pipeline = (Array.isArray(stages) ? stages : []).map(stage => {
169
+ const type = String(stage?.type || '');
170
+ if (PROTOTYPE_KEYS.has(type)) throw new UnsafeQueryError(`"${type}" is not a stage.`);
171
+ return {[type]: stage.config};
172
+ });
173
+ assertSafeMongoQuery(pipeline, opts);
174
+ // A copy, so the saved view is not rewritten: every $lookup names its cms_ collection.
175
+ const out = structuredClone(pipeline);
176
+ const prefixLookups = (node) => {
177
+ if (!node || typeof node !== 'object') return;
178
+ if (Array.isArray(node)) return node.forEach(prefixLookups);
179
+ for (const [k, v] of Object.entries(node)) {
180
+ if (k === '$lookup' && typeof v?.from === 'string' && !v.from.startsWith('cms_')) v.from = `cms_${v.from}`;
181
+ prefixLookups(v);
182
+ }
183
+ };
184
+ prefixLookups(out);
185
+ if (opts.collectionExists) {
186
+ for (const from of lookupTargets(out)) {
187
+ if (!(await opts.collectionExists(from.slice('cms_'.length)))) {
188
+ throw new UnsafeQueryError(`$lookup names "${from}", which is not a collection on this site.`);
189
+ }
190
+ }
191
+ }
192
+ return out;
193
+ }
@@ -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
+ }
@@ -13,13 +13,16 @@
13
13
  * exception is a SUPER-ADMIN (role level 0), which on the fleet exists only on
14
14
  * the manager: the people who publish the paid plugins are the people who need
15
15
  * to read and edit them. Customer sites are all level 1, so nothing changes
16
- * there - see the `allowLicensed` argument, set from the request's user.
16
+ * there - see the `allowLicensed` argument, set from the request's user. (Hosted
17
+ * owners are in fact level 0 on their own site: on a signed-only site the route
18
+ * never allows licensed source - services/codePolicy.js.)
17
19
  *
18
20
  * Thrown errors carry `statusCode` (400 invalid, 403 licensed, 404 missing,
19
21
  * 413 too large) for direct mapping in the route layer.
20
22
  */
21
23
  import fs from 'fs/promises';
22
24
  import path from 'path';
25
+ import {assertMayChangeCode} from './codePolicy.js';
23
26
 
24
27
  const PLUGINS_DIR = path.resolve('plugins');
25
28
 
@@ -200,6 +203,8 @@ export async function readPluginFile(name, relPath, {allowLicensed = false} = {}
200
203
  * @returns {Promise<{path: string, restartRequired: boolean}>}
201
204
  */
202
205
  export async function writePluginFile(name, relPath, content, {allowLicensed = false} = {}) {
206
+ // A signed-only site runs the Marketplace's code as signed: nothing here may change it.
207
+ assertMayChangeCode();
203
208
  const pluginDir = await resolvePluginDir(name, allowLicensed);
204
209
  const abs = await resolveFilePath(pluginDir, relPath);
205
210
 
@@ -240,6 +245,8 @@ export async function writePluginFile(name, relPath, content, {allowLicensed = f
240
245
  * @returns {Promise<{path: string}>}
241
246
  */
242
247
  export async function deletePluginFile(name, relPath, {allowLicensed = false} = {}) {
248
+ // A signed-only site runs the Marketplace's code as signed: nothing here may change it.
249
+ assertMayChangeCode();
243
250
  const pluginDir = await resolvePluginDir(name, allowLicensed);
244
251
  const abs = await resolveFilePath(pluginDir, relPath);
245
252
  const rel = path.relative(pluginDir, abs).split(path.sep).join('/');