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.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +29 -1
- package/admin/js/api.js +1 -1
- package/admin/js/views/plugin-code.js +3 -3
- package/admin/js/views/plugins.js +8 -8
- package/docs/DommaFeatures.md +2 -1
- package/docs/configuration.md +2 -1
- package/package.json +1 -1
- package/server/config.js +3 -2
- package/server/env.js +12 -0
- package/server/routes/api/collections.js +9 -0
- package/server/routes/api/forms.js +9 -4
- package/server/routes/api/plugin-marketplace.js +1 -2
- package/server/routes/api/plugins.js +25 -3
- package/server/server.js +1 -1
- package/server/services/actions.js +5 -2
- package/server/services/adapters/FileAdapter.js +2 -1
- package/server/services/adapters/MongoAdapter.js +22 -6
- package/server/services/codePolicy.js +111 -0
- package/server/services/collections.js +2 -1
- package/server/services/components.js +3 -2
- package/server/services/connectionManager.js +8 -0
- package/server/services/content.js +1 -1
- package/server/services/contextMenus.js +2 -1
- package/server/services/filterEngine.js +3 -1
- package/server/services/forms.js +4 -3
- package/server/services/menus.js +2 -1
- package/server/services/mongoQueryGuard.js +193 -0
- package/server/services/outbound.js +394 -0
- package/server/services/pluginFiles.js +8 -1
- package/server/services/pluginInstaller.js +16 -13
- package/server/services/pluginScaffold.js +3 -0
- package/server/services/safePath.js +35 -0
- package/server/services/staffAccess.js +2 -1
- package/server/services/versions.js +1 -1
- package/server/services/views.js +17 -4
- package/server/services/wpImport/fetch.js +12 -181
|
@@ -16,9 +16,9 @@ import {readFileSync} from 'fs';
|
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import zlib from 'zlib';
|
|
18
18
|
import {fileURLToPath} from 'url';
|
|
19
|
-
import {getConfig} from '../config.js';
|
|
20
19
|
import {discoverPlugins, pluginRoleHooks, unregisterAllRolesForPlugin, validatePluginDir} from './plugins.js';
|
|
21
20
|
import {allowInGitignore, manifestContentPaths, pluginContentRules} from './siteGitignore.js';
|
|
21
|
+
import {signedOnly, signedOnlyError} from './codePolicy.js';
|
|
22
22
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
// Paths
|
|
@@ -74,18 +74,14 @@ MCowBQYDK2VwAyEAqAlx1lfHeCaNiwJmoXZMDY56yafU2GlpNG3VInw9Q14=
|
|
|
74
74
|
* Does this site refuse anything the marketplace has not signed?
|
|
75
75
|
*
|
|
76
76
|
* Default false, because a CMS whose own scaffolder produces unsigned plugins
|
|
77
|
-
* cannot refuse unsigned plugins without refusing its user's own work. A
|
|
78
|
-
* site
|
|
79
|
-
*
|
|
77
|
+
* cannot refuse unsigned plugins without refusing its user's own work. A hosted
|
|
78
|
+
* site has it on (services/codePolicy.js - the one place that decides, and the
|
|
79
|
+
* other doors it closes).
|
|
80
80
|
*
|
|
81
81
|
* @returns {boolean}
|
|
82
82
|
*/
|
|
83
83
|
function requireSignedPlugins() {
|
|
84
|
-
|
|
85
|
-
return getConfig('server')?.requireSignedPlugins === true;
|
|
86
|
-
} catch {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
84
|
+
return signedOnly();
|
|
89
85
|
}
|
|
90
86
|
|
|
91
87
|
/**
|
|
@@ -348,12 +344,12 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
|
|
|
348
344
|
// Step 1 - Verify signature, or establish that there is deliberately none.
|
|
349
345
|
const unsigned = !signature;
|
|
350
346
|
if (unsigned) {
|
|
347
|
+
// Signed-only first: asking the admin to confirm something the site will
|
|
348
|
+
// refuse anyway would be a question with no right answer.
|
|
349
|
+
if (requireSignedPlugins()) throw signedOnlyError();
|
|
351
350
|
if (!allowUnsigned) {
|
|
352
351
|
throw new Error('This plugin is unsigned. Installing it runs its code on this server, so it has to be confirmed.');
|
|
353
352
|
}
|
|
354
|
-
if (requireSignedPlugins()) {
|
|
355
|
-
throw new Error('This site accepts signed plugins only (server.json: requireSignedPlugins).');
|
|
356
|
-
}
|
|
357
353
|
}
|
|
358
354
|
|
|
359
355
|
const pubKeyPem = unsigned ? null : trustedKey(publicKeyId, 'bundle');
|
|
@@ -396,7 +392,14 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
|
|
|
396
392
|
throw err;
|
|
397
393
|
}
|
|
398
394
|
|
|
399
|
-
// Step 4 - Validate manifest
|
|
395
|
+
// Step 4 - Validate manifest. discoverPlugins() names a plugin after its
|
|
396
|
+
// folder, so it cannot tell a bundle installed under someone else's slug -
|
|
397
|
+
// the manifest inside must name this slug itself (as an upgrade checks).
|
|
398
|
+
const own = await validatePluginDir(destDir, slug);
|
|
399
|
+
if (!own.ok) {
|
|
400
|
+
await fs.rm(destDir, { recursive: true, force: true });
|
|
401
|
+
throw new Error(`Plugin manifest validation failed after extraction: ${slug} (${own.reason})`);
|
|
402
|
+
}
|
|
400
403
|
const manifests = await discoverPlugins();
|
|
401
404
|
const manifest = manifests.find(m => m.name === slug);
|
|
402
405
|
if (!manifest) {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import fs from 'fs/promises';
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import {allowInGitignore, sitePluginRules} from './siteGitignore.js';
|
|
15
|
+
import {assertMayChangeCode} from './codePolicy.js';
|
|
15
16
|
|
|
16
17
|
const PLUGINS_DIR = path.resolve('plugins');
|
|
17
18
|
const TEMPLATE_DIR = path.join(PLUGINS_DIR, '_template');
|
|
@@ -71,6 +72,8 @@ export function validateScaffoldInput(input = {}) {
|
|
|
71
72
|
* @returns {Promise<{name: string, files: string[]}>} Created plugin name and relative file list
|
|
72
73
|
*/
|
|
73
74
|
export async function scaffoldPlugin(input) {
|
|
75
|
+
// A new plugin is new server code: a signed-only site makes none (services/codePolicy.js).
|
|
76
|
+
assertMayChangeCode();
|
|
74
77
|
const { slug, displayName, description, author, icon } = validateScaffoldInput(input);
|
|
75
78
|
|
|
76
79
|
try {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One name, one file, inside one folder.
|
|
3
|
+
*
|
|
4
|
+
* Fastify DECODES route params, so `/api/forms/..%2F..%2Fconfig%2Fserver`
|
|
5
|
+
* arrives as the slug `../../config/server`. A store that builds its file as
|
|
6
|
+
* `path.join(DIR, `${slug}.json`)` then reads and writes `config/server.json` -
|
|
7
|
+
* which is how a site admin could rewrite the host's own settings (switching
|
|
8
|
+
* `requireSignedPlugins` off, unlocking a Tool) through an ordinary form save.
|
|
9
|
+
* Routes check slugs in most places; this is the check at the file, so a route
|
|
10
|
+
* that forgets is still safe.
|
|
11
|
+
*
|
|
12
|
+
* @module services/safePath
|
|
13
|
+
*/
|
|
14
|
+
import path from 'path';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The file `<dir>/<name><ext>`, refusing any name that is not one plain path
|
|
18
|
+
* segment (a separator, `.`/`..`, a NUL, empty, or not a string).
|
|
19
|
+
*
|
|
20
|
+
* @param {string} dir
|
|
21
|
+
* @param {string} name
|
|
22
|
+
* @param {string} [ext] e.g. '.json'; '' for a folder
|
|
23
|
+
* @returns {string}
|
|
24
|
+
* @throws {Error} statusCode 404 - to a caller, a name like that names nothing
|
|
25
|
+
*/
|
|
26
|
+
export function fileIn(dir, name, ext = '') {
|
|
27
|
+
const root = path.resolve(dir);
|
|
28
|
+
const ok = typeof name === 'string' && name !== '' && name !== '.' && name !== '..'
|
|
29
|
+
&& !/[\\/\0]/.test(name);
|
|
30
|
+
const file = ok ? path.join(root, `${name}${ext}`) : null;
|
|
31
|
+
if (!file || path.dirname(file) !== root) {
|
|
32
|
+
throw Object.assign(new Error(`Not found: ${String(name).slice(0, 80)}`), {statusCode: 404, code: 'ENOENT'});
|
|
33
|
+
}
|
|
34
|
+
return file;
|
|
35
|
+
}
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Anyone who can read the site's JWT_SECRET (shell access, or code running in
|
|
26
26
|
* the site's process - which includes a level-0 user installing an unsigned
|
|
27
|
-
* plugin, unless
|
|
27
|
+
* plugin or editing one, unless the site is signed-only - services/codePolicy.js,
|
|
28
|
+
* which hosted sites are) can mint either;
|
|
28
29
|
* that is true of every session. The session is the site's full top role for
|
|
29
30
|
* an hour (the Manager can already act as it), with no `sid` to revoke it.
|
|
30
31
|
*
|
|
@@ -37,7 +37,7 @@ const PAGES_DIR = path.join(config.content.contentDir, 'pages');
|
|
|
37
37
|
function versionsDirForPath(urlPath) {
|
|
38
38
|
const normalised = urlPath === '/' || !urlPath ? '_index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
|
|
39
39
|
const resolved = path.resolve(path.join(VERSIONS_DIR, normalised));
|
|
40
|
-
if (!resolved.startsWith(path.resolve(VERSIONS_DIR))) {
|
|
40
|
+
if (resolved !== path.resolve(VERSIONS_DIR) && !resolved.startsWith(path.resolve(VERSIONS_DIR) + path.sep)) {
|
|
41
41
|
throw new Error('Invalid path: outside versions directory');
|
|
42
42
|
}
|
|
43
43
|
return resolved;
|
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';
|
|
@@ -34,6 +39,8 @@ import {checkVisibility} from '../middleware/auth.js';
|
|
|
34
39
|
import {getEffectiveLevel} from './userRoles.js';
|
|
35
40
|
import {executePipeline} from './viewPipeline.js';
|
|
36
41
|
import * as cache from './cache/index.js';
|
|
42
|
+
import {fileIn} from './safePath.js';
|
|
43
|
+
import {assertSafeMongoQuery, toSafeMongoPipeline} from './mongoQueryGuard.js';
|
|
37
44
|
|
|
38
45
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
46
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -87,6 +94,7 @@ function validateStages(stages) {
|
|
|
87
94
|
if (!ALLOWED_STAGES.has(stage.type)) {
|
|
88
95
|
throw new Error(`Stage type "${stage.type}" is not in the allowed list`);
|
|
89
96
|
}
|
|
97
|
+
assertSafeMongoQuery({[stage.type]: stage.config});
|
|
90
98
|
}
|
|
91
99
|
}
|
|
92
100
|
|
|
@@ -122,7 +130,7 @@ export async function ensureViewsDir() {
|
|
|
122
130
|
* @throws {Error} If the view file does not exist or cannot be parsed.
|
|
123
131
|
*/
|
|
124
132
|
export async function readView(slug) {
|
|
125
|
-
const file =
|
|
133
|
+
const file = fileIn(VIEWS_DIR, slug, '.json');
|
|
126
134
|
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
127
135
|
}
|
|
128
136
|
|
|
@@ -135,7 +143,7 @@ export async function readView(slug) {
|
|
|
135
143
|
*/
|
|
136
144
|
export async function writeView(slug, view) {
|
|
137
145
|
await ensureViewsDir();
|
|
138
|
-
const file =
|
|
146
|
+
const file = fileIn(VIEWS_DIR, slug, '.json');
|
|
139
147
|
await fs.writeFile(file, JSON.stringify(view, null, 4) + '\n', 'utf8');
|
|
140
148
|
await cache.invalidateTags([`view:${slug}`]);
|
|
141
149
|
}
|
|
@@ -368,7 +376,7 @@ export async function deleteView(slug) {
|
|
|
368
376
|
throw new Error(`View "${slug}" not found`);
|
|
369
377
|
}
|
|
370
378
|
try {
|
|
371
|
-
await fs.unlink(
|
|
379
|
+
await fs.unlink(fileIn(VIEWS_DIR, slug, '.json'));
|
|
372
380
|
} catch {
|
|
373
381
|
throw new Error(`View "${slug}" not found`);
|
|
374
382
|
}
|
|
@@ -388,7 +396,12 @@ async function executeMongoView(view, { page, limit, user }) {
|
|
|
388
396
|
const db = getDb(view.connection || 'default');
|
|
389
397
|
|
|
390
398
|
const sourceCollection = `${COLLECTION_PREFIX}${view.pipeline.source}`;
|
|
391
|
-
|
|
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
|
+
});
|
|
392
405
|
|
|
393
406
|
// Prepend row-level $match if configured
|
|
394
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
|
}
|