domma-cms 1.7.1 → 1.7.2
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 +4 -0
- package/CLAUDE.md +30 -0
- package/admin/js/app.js +2 -2
- package/admin/js/lib/wordpress-import-arrange.js +1 -0
- package/admin/js/templates/docs/usage-tools.html +13 -1
- package/admin/js/templates/wordpress-import.html +223 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/wordpress-import.js +38 -0
- package/package.json +1 -1
- package/server/routes/api/wordpress-import.js +220 -0
- package/server/server.js +12 -0
- package/server/services/hooks.js +1 -1
- package/server/services/permissionRegistry.js +10 -0
- package/server/services/roles.js +1 -1
- package/server/services/tools.js +2 -1
- package/server/services/wpImport/convert.js +491 -0
- package/server/services/wpImport/fetch.js +264 -0
- package/server/services/wpImport/index.js +832 -0
- package/server/services/wpImport/plan.js +549 -0
- package/server/services/wpImport/store.js +242 -0
- package/server/services/wpImport/wxr.js +167 -0
- package/server/services/wpImport/xml.js +261 -0
- package/server/services/wpImport/zip.js +142 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downloading media from the old WordPress site - without becoming a way to
|
|
3
|
+
* reach anything else (SSRF).
|
|
4
|
+
*
|
|
5
|
+
* - Only the WordPress site's own host names (from its export) are fetched,
|
|
6
|
+
* over http(s), on ports 80 and 443 only.
|
|
7
|
+
* - The host name is resolved first, and EVERY address it resolves to must be
|
|
8
|
+
* public: loopback, private, link-local (cloud metadata lives at
|
|
9
|
+
* 169.254.169.254), CGNAT, multicast, reserved and documentation ranges are
|
|
10
|
+
* refused, IPv4-mapped and NAT64 IPv6 forms included. The connection then
|
|
11
|
+
* goes to the address that was checked (a pinned lookup), so a second DNS
|
|
12
|
+
* answer cannot swap in an internal one.
|
|
13
|
+
* - Redirects are followed only to the same allowed hosts (each re-checked),
|
|
14
|
+
* at most three.
|
|
15
|
+
* - A size cap (refused early on Content-Length, cut off while streaming), a
|
|
16
|
+
* time limit for the whole download, and only 200 answers.
|
|
17
|
+
*
|
|
18
|
+
* `allowPrivate` exists for tests and the local browser check, which serve
|
|
19
|
+
* files from 127.0.0.1; the runner only passes it when testHostsAllowed() says
|
|
20
|
+
* so - never in production.
|
|
21
|
+
*
|
|
22
|
+
* @module services/wpImport/fetch
|
|
23
|
+
*/
|
|
24
|
+
import dns from 'node:dns';
|
|
25
|
+
import http from 'node:http';
|
|
26
|
+
import https from 'node:https';
|
|
27
|
+
import net from 'node:net';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
|
|
30
|
+
export const FETCH_PORTS = Object.freeze([80, 443]);
|
|
31
|
+
|
|
32
|
+
/** The port an address would be fetched on. */
|
|
33
|
+
export function portOf(raw) {
|
|
34
|
+
try { const u = new URL(raw); return u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80); } catch { return 0; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const FETCH_LIMITS = Object.freeze({maxBytes: 50 * 1024 * 1024, timeoutMs: 60_000, maxRedirects: 3});
|
|
38
|
+
|
|
39
|
+
export class FetchRefused extends Error {
|
|
40
|
+
constructor(message) { super(message); this.code = 'EREFUSED_SSRF'; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The test-only switch: private addresses are allowed only outside
|
|
45
|
+
* production AND with WP_IMPORT_ALLOW_PRIVATE_HOSTS=1 set.
|
|
46
|
+
*/
|
|
47
|
+
export function testHostsAllowed(env = process.env) {
|
|
48
|
+
return env.NODE_ENV !== 'production' && env.WP_IMPORT_ALLOW_PRIVATE_HOSTS === '1';
|
|
49
|
+
}
|
|
50
|
+
|
|
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
|
+
/** May this URL be fetched at all (before any DNS)? Returns the URL, or throws. */
|
|
167
|
+
export function checkUrl(raw, {hosts = [], allowPrivate = false} = {}) {
|
|
168
|
+
let u;
|
|
169
|
+
try { u = new URL(String(raw || '')); } catch { throw new FetchRefused('Not a web address.'); }
|
|
170
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new FetchRefused(`${u.protocol} addresses are not fetched.`);
|
|
171
|
+
if (u.username || u.password) throw new FetchRefused('Addresses with a user name or password are not fetched.');
|
|
172
|
+
const host = u.hostname.toLowerCase();
|
|
173
|
+
if (!hosts.includes(host)) throw new FetchRefused(`${host} is not the WordPress site's own host - not fetched.`);
|
|
174
|
+
const port = u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80);
|
|
175
|
+
// Web ports only. The tests' local server is the one exception (testHostsAllowed, never in production).
|
|
176
|
+
if (!allowPrivate && !FETCH_PORTS.includes(port)) throw new FetchRefused(`Port ${port} is not fetched - only 80 and 443.`);
|
|
177
|
+
return u;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Download one file.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} url
|
|
184
|
+
* @param {object} opts
|
|
185
|
+
* @param {string[]} opts.hosts - host names allowed (the WordPress site's own)
|
|
186
|
+
* @param {number} [opts.maxBytes]
|
|
187
|
+
* @param {number} [opts.timeoutMs]
|
|
188
|
+
* @param {number} [opts.maxRedirects]
|
|
189
|
+
* @param {boolean} [opts.allowPrivate] - tests only (see testHostsAllowed)
|
|
190
|
+
* @param {Function} [opts.lookup] - dns.promises.lookup, swappable in tests
|
|
191
|
+
* @param {AbortSignal} [opts.signal]
|
|
192
|
+
* @returns {Promise<{buffer: Buffer, contentType: string, url: string}>}
|
|
193
|
+
*/
|
|
194
|
+
export async function fetchMedia(url, opts = {}) {
|
|
195
|
+
const lim = {...FETCH_LIMITS, ...opts};
|
|
196
|
+
const deadline = Date.now() + lim.timeoutMs;
|
|
197
|
+
let current = url;
|
|
198
|
+
for (let hop = 0; hop <= lim.maxRedirects; hop++) {
|
|
199
|
+
const u = checkUrl(current, opts);
|
|
200
|
+
const {address, family} = await resolvePublic(u.hostname, opts);
|
|
201
|
+
const res = await request(u, {address, family, deadline, maxBytes: lim.maxBytes, signal: opts.signal});
|
|
202
|
+
if (res.redirect) {
|
|
203
|
+
if (hop === lim.maxRedirects) throw new FetchRefused('Too many redirects.');
|
|
204
|
+
current = new URL(res.redirect, u).href;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
return {buffer: res.buffer, contentType: res.contentType, url: u.href};
|
|
208
|
+
}
|
|
209
|
+
throw new FetchRefused('Too many redirects.');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function request(u, {address, family, deadline, maxBytes, signal}) {
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
215
|
+
let done = false;
|
|
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();
|
|
263
|
+
});
|
|
264
|
+
}
|