domma-cms 1.8.14 → 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.
- package/CHANGELOG.md +5 -0
- package/CLAUDE.md +12 -1
- package/docs/configuration.md +1 -0
- package/package.json +1 -1
- package/server/routes/api/forms.js +9 -4
- package/server/services/actions.js +5 -2
- package/server/services/adapters/MongoAdapter.js +22 -6
- package/server/services/filterEngine.js +3 -1
- package/server/services/mongoQueryGuard.js +193 -0
- package/server/services/outbound.js +394 -0
- package/server/services/views.js +13 -1
- package/server/services/wpImport/fetch.js +12 -181
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.8.15] - 2026-10-16
|
|
4
|
+
|
|
5
|
+
- **Fixed: a view or filter on a MongoDB collection could run JavaScript inside the database server.** A saved view, the view editor's live results, a `[view]` or `[collection]` on a page, a collection filter, an `/api/v1` query or an API Builder endpoint could carry `$where`, `$function`, `$accumulator` and the like - hidden at any depth - and MongoDB would run it. Every query is now checked in one place before MongoDB sees it, and refused with a plain message if it holds anything that runs code, writes (`$out`, `$merge`), reads beyond the site's own collections (`$unionWith`, `$graphLookup`, a `$lookup` into another database or something that is not one of the site's collections) or uses a name like `__proto__`. A view is checked when it is saved, too. Ordinary filters and views work as before; a `$lookup` that names a collection by its plain name (`people` rather than `cms_people`) now works on MongoDB as it already did on files. An entry id sent as anything but text (`{"$ne": ""}` in an Action's run request, say) no longer picks whichever MongoDB entry matched first.
|
|
6
|
+
- **Fixed: webhooks could call addresses inside the server's own network.** An Action's webhook step and a form's webhook would send a request anywhere - `http://127.0.0.1:…`, a private LAN address, or the cloud metadata address. They now reach public addresses only: the name is looked up first and every address it gives must be public and not this server's own, the request goes to the address that was checked (so a second lookup cannot swap in an internal one), redirects into a private address are refused, and only ports 80 and 443 are used. A webhook's own headers (an API key, say) are not passed on when it redirects to another site, and it cannot set `Host`. A form's webhook that answers with an error is now reported like any other failed form action. Self-hosted sites that send webhooks to their own network can allow it in `config/server.json` with `outboundAllowPrivate` - `true`, or a list of host names, addresses and ranges. It has no effect on a site its host runs signed-only (every site Domma CMS Manager hosts).
|
|
7
|
+
|
|
3
8
|
## [1.8.14] - 2026-10-16
|
|
4
9
|
|
|
5
10
|
- **Signed-only sites close every way of putting plugin code on the server.** With `requireSignedPlugins: true` in `config/server.json` (or `DOMMA_SIGNED_ONLY=1` in the environment, which Domma CMS Manager sets on the sites it hosts), unsigned plugin files are refused straight away with a plain message - no "install anyway?" question first - and New plugin and changes in a plugin's code editor are refused too: the Marketplace screen leaves New plugin off, and the code editor opens read only. Installing and updating from the Marketplace work as before. A licensed plugin's source stays closed on such a site, Super Admins included. MongoDB connection options that could start a program or read a file on the server (`autoEncryption`, `tls…File` and the like) are refused there as well. Self-hosted sites, where the setting is off, are unchanged.
|
package/CLAUDE.md
CHANGED
|
@@ -125,6 +125,17 @@ Collection entry I/O is delegated to a storage adapter. Schema management is alw
|
|
|
125
125
|
- System preset collections (`presetCollections.js` - roles, user profiles, projects, notifications, api tokens/endpoints) are **always** file-based whatever their schema says, and cannot be moved (`isFixedToFile()`)
|
|
126
126
|
- **Exception - the user-data presets** (`USER_DATA_PRESET_SLUGS`: `notes-notes`, `todo-items`, `contacts-contacts`, `contacts-groups`): `resolveUserDataPreset()` in `adapterRegistry.js` uses MongoDB only when the schema declares it (notes and todo do by default, contacts declare file), the named connection is connected, AND their `data.json` holds no rows; otherwise files. Rows in files (or in both stores) keep it on files and are logged with counts (`getStorageConflicts()`), because switching would show users an empty store - the Storage move (id-preserving) settles it. No connection configured = files, silently (MongoDB is Pro); configured but down = files + the storage-down notice. `seedAll()` keeps these presets' existing `storage` block, so a move survives a restart
|
|
127
127
|
- Absence of `config/connections.json` = file mode (server.js wraps Mongo init in try/catch)
|
|
128
|
+
- **User-shaped queries reach MongoDB only through `mongoQueryGuard.js`**: `toSafeMongoQuery()` (MongoAdapter.list - so every
|
|
129
|
+
filter source: `[collection]`, `/api/v1` params, API Builder) and `toSafeMongoPipeline()` (views' native aggregation;
|
|
130
|
+
`validateStages()` runs the same check on save). Refused at any depth, dotted keys a part at a time: code
|
|
131
|
+
(`$where`, `$function`, `$accumulator`, BSON Code), writes/other data (`$out`, `$merge`, `$unionWith`,
|
|
132
|
+
`$graphLookup`, server-state stages, a `$lookup` not into a site `cms_<slug>` collection) and `__proto__`/`constructor`/
|
|
133
|
+
`prototype`. A new path that hands user input to `find`/`aggregate` must go through one of the two
|
|
134
|
+
- **Outbound requests to user-chosen URLs go through `services/outbound.js` `safeFetch()`** (Actions' webhook step,
|
|
135
|
+
form webhooks; plugins fetching a user's URL should too) - never bare `fetch`. Public addresses only after DNS, the
|
|
136
|
+
connection pinned to the checked address, never this server's own addresses, redirects re-checked, ports 80/443.
|
|
137
|
+
`server.json` `outboundAllowPrivate` (`true` or a list of hosts/addresses/CIDRs) lifts it for self-hosters and is
|
|
138
|
+
ignored when `codePolicy.signedOnly()`. `wpImport/fetch.js` shares its address rules and pinned request
|
|
128
139
|
|
|
129
140
|
## Roles & Permissions
|
|
130
141
|
|
|
@@ -367,7 +378,7 @@ restarting) mid-run - keep that file's name and meaning.
|
|
|
367
378
|
- **The XML reader is its own** (`xml.js`): any DOCTYPE or ENTITY is refused, only the predefined and numeric
|
|
368
379
|
entities decode - do not swap in a parser that expands entities. Zips (`zip.js`) are read by offset with
|
|
369
380
|
zip-slip names dropped and ratio/size caps; nothing is ever extracted under its own name.
|
|
370
|
-
- **Media downloads go through `fetch.js` only
|
|
381
|
+
- **Media downloads go through `fetch.js` only** (address rules and pinned request shared with `services/outbound.js`): the export's own hosts, every resolved address public, the
|
|
371
382
|
connection pinned to the checked address, never the server's own interface addresses, ports 80/443 only, no
|
|
372
383
|
redirect off-host; a non-image/PDF body that sniffs as HTML or script is refused (it would be served from /media).
|
|
373
384
|
`WP_IMPORT_ALLOW_PRIVATE_HOSTS=1` (ignored when `NODE_ENV=production`) is the tests' and local browser checks' way
|
package/docs/configuration.md
CHANGED
|
@@ -31,6 +31,7 @@ Controls the HTTP server.
|
|
|
31
31
|
| `logging.level` | string | Log verbosity: `fatal`, `error`, `warn`, `info`, `debug`, `trace`, `silent`. Defaults to `info` in development, `warn` otherwise. `warn` hides 4xx responses and plugin `log.info` output, so a failed admin action leaves no trace - raise it to `info` if you need to diagnose one. Overridden by the `LOG_LEVEL` env var. |
|
|
32
32
|
| `restartOnPluginToggle` | boolean | `true` | Restart the server automatically when an admin enables or disables a plugin. See **Plugin toggles restart the server** below |
|
|
33
33
|
| `requireSignedPlugins` | boolean | `false` | When `true`, the site runs only plugin code the Marketplace signed: an unsigned `.dcmsplugin` (e.g. one exported from another site) is refused even if the installer confirms it, New plugin is refused, a plugin's files cannot be changed in the code editor (it opens read only), licensed source cannot be read even by a Super Admin, and MongoDB connection options that start a program or read a file (`autoEncryption`, `tls…File`, …) cannot be saved. A bundle with a signature that does not verify is always refused, whatever this is set to. Read fresh on every check, and also as it was at boot, so changing the file on a running site can switch it on but not off. `DOMMA_SIGNED_ONLY=1` in the environment turns it on whatever the file says - Domma CMS Manager sets it on the sites it hosts. Nothing in the admin writes `server.json`. See `server/services/codePolicy.js` |
|
|
34
|
+
| `outboundAllowPrivate` | boolean \| string[] | `false` | Webhooks (an Action's webhook step, a form's webhook) reach public addresses only: the name is looked up and every address must be public and not the server's own, the request is pinned to that address, redirects into private addresses are refused, and only ports 80 and 443 are used. A self-hosted site that sends webhooks inside its own network sets `true` to lift those rules for every request, or a list of host names, addresses and CIDR ranges (`["n8n.lan", "192.168.10.0/24", "fd00::/8"]`) to lift them for those only - a name passes if it is listed, or if every address it resolves to is. Ignored on a signed-only site (`requireSignedPlugins` / `DOMMA_SIGNED_ONLY=1`), so a hosted site's admin cannot open the host's network. Read fresh on every request. See `server/services/outbound.js` |
|
|
34
35
|
|
|
35
36
|
### Plugin toggles restart the server
|
|
36
37
|
|
package/package.json
CHANGED
|
@@ -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);
|
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
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
|
}
|