17
const fs = require('fs');
18
const crypto = require('crypto');
19
const path = require('path');
20
+const { URL } = require('url');
21
22
// Binary encoding and decoding functions
23
module.exports.ReadShort = function (v, p) { return (v.charCodeAt(p) << 8) + v.charCodeAt(p + 1); };
217
218
return true;
219
}
220
+
221
+// Validate that a string is a parseable http or https URL with a hostname
222
+module.exports.validateUrl = function (url) {
223
+ if (!module.exports.validateString(url, 1, 4096)) return false;
224
+ try {
225
+ const u = new URL(url);
226
+ const scheme = (u.protocol || '').replace(/:$/, '').toLowerCase();
227
+ if (scheme !== 'http' && scheme !== 'https') return false;
228
+ if (!u.hostname || u.hostname.length === 0) return false;
229
+ return true;
230
+ } catch (ex) {
231
+ return false;
232
+ }
233
+}
234
+
235
+// Validate a remote image URL by checking headers and magic bytes (PNG/JPEG/GIF/WEBP/ICO)
236
+// Returns a Promise<boolean> that always resolves to true (valid image) or false (invalid/error) and never rejects.
237
+module.exports.validateRemoteImage = function (url, options) {
238
+ options = options || {};
239
+ const timeoutMs = (typeof options.timeoutMs === 'number') ? options.timeoutMs : 5000;
240
+ const maxHeadBytes = (typeof options.maxHeadBytes === 'number') ? options.maxHeadBytes : 16384; // 16 KB
241
+ const maxContentLength = (typeof options.maxContentLength === 'number') ? options.maxContentLength : (5 * 1024 * 1024); // 5 MB
242
+ const allowedMimes = options.allowedMimes || ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/x-icon', 'image/vnd.microsoft.icon', 'image/svg+xml'];
243
+ const agent = options.agent || undefined;
244
+ // This function MUST always resolve to boolean true/false and never reject.
245
+ return new Promise((resolve) => {
246
+ try {
247
+ if (!module.exports.validateUrl(url)) return resolve(false);
248
+
249
+ const http = require('http');
250
+ const https = require('https');
251
+
252
+ function doRequest(method, reqUrl, headers, redirectsLeft, cb) {
253
+ try {
254
+ const u = new URL(reqUrl);
255
+ const lib = (u.protocol === 'https:') ? https : http;
256
+ const reqOpts = { method: method, headers: headers || {}, timeout: timeoutMs };
257
+ if (agent) { reqOpts.agent = agent; }
258
+ const req = lib.request(u, reqOpts, (res) => {
259
+ // Follow redirects (3xx)
260
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
261
+ res.resume();
262
+ const redirectUrl = new URL(res.headers.location, u).toString();
263
+ // Re-validate each redirect target
264
+ if (!module.exports.validateUrl(redirectUrl)) {
265
+ return cb(new Error('Invalid redirect URL'));
266
+ }
267
+ return doRequest(method, redirectUrl, headers, redirectsLeft - 1, cb);
268
+ }
269
+ cb(null, res, u.toString());
270
+ });
271
+ req.on('error', (e) => cb(e));
272
+ req.on('timeout', () => { req.destroy(new Error('timeout')); });
273
+ req.end();
274
+ } catch (ex) { cb(ex); }
275
+ }
276
+
277
+ // Centralized magic-byte detector
278
+ function detectBuffer(b) {
279
+ if (!b || b.length < 4) return null;
280
+ // PNG
281
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47 && b[4] === 0x0D && b[5] === 0x0A && b[6] === 0x1A && b[7] === 0x0A) return { mime: 'image/png', ext: 'png' };
282
+ // JPEG
283
+ if (b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF) return { mime: 'image/jpeg', ext: 'jpg' };
284
+ // GIF
285
+ if (b.length >= 6 && b.slice(0, 3).toString('ascii') === 'GIF') return { mime: 'image/gif', ext: 'gif' };
286
+ // WEBP (RIFF....WEBP)
287
+ if (b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'WEBP') return { mime: 'image/webp', ext: 'webp' };
288
+ // ICO (00 00 01 00)
289
+ if (b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00) return { mime: 'image/x-icon', ext: 'ico' };
290
+ // SVG (text-based XML; look for '<svg' or '<?xml' after optional whitespace/BOM)
291
+ try {
292
+ var svgHead = b.slice(0, Math.min(b.length, 256)).toString('utf8').trimStart();
293
+ if (svgHead.charCodeAt(0) === 0xFEFF) { svgHead = svgHead.slice(1); } // strip BOM
294
+ if (svgHead.startsWith('<svg') || svgHead.startsWith('<?xml')) return { mime: 'image/svg+xml', ext: 'svg' };
295
+ } catch (e) {}
296
+ return null;
297
+ }
298
+
299
+ // Inspect an incoming GET response stream for image magic bytes.
300
+ function inspectStreamForImage(getRes) {
301
+ return new Promise((resolveInspect) => {
302
+ try {
303
+ const chunks = [];
304
+ let length = 0;
305
+ let timedOut = false;
306
+ let finished = false;
307
+ const to = setTimeout(() => { timedOut = true; try { getRes.destroy(new Error('timeout')); } catch (e) {} }, timeoutMs + 1000);
308
+
309
+ function finalize(result) {
310
+ if (finished) return;
311
+ finished = true;
312
+ clearTimeout(to);
313
+ try { getRes.removeAllListeners('data'); getRes.removeAllListeners('end'); getRes.removeAllListeners('close'); getRes.removeAllListeners('error'); } catch (e) {}
314
+ try { return resolveInspect(result); } catch (e) { return; }
315
+ }
316
+
317
+ getRes.on('data', (chunk) => {
318
+ if (timedOut || finished) return;
319
+ if (length < maxHeadBytes) {
320
+ const remaining = maxHeadBytes - length;
321
+ if (chunk.length <= remaining) {
322
+ chunks.push(chunk);
323
+ length += chunk.length;
324
+ } else {
325
+ chunks.push(chunk.slice(0, remaining));
326
+ length += remaining;
327
+ }
328
+ }
329
+ if (length >= maxHeadBytes) {
330
+ try {
331
+ const buf = Buffer.concat(chunks, Math.min(length, maxHeadBytes));
332
+ const det = detectBuffer(buf);
333
+ if (!det) finalize(false);
334
+ else if (allowedMimes.indexOf(det.mime) === -1) finalize(false);
335
+ else finalize(true);
336
+ } catch (ex) { finalize(false); }
337
+ try { getRes.destroy(); } catch (e) {}
338
+ }
339
+ });
340
+
341
+ getRes.on('end', () => {
342
+ if (finished) return;
343
+ try {
344
+ const buf = Buffer.concat(chunks, Math.min(length, maxHeadBytes));
345
+ const det = detectBuffer(buf);
346
+ if (!det) finalize(false);
347
+ else if (allowedMimes.indexOf(det.mime) === -1) finalize(false);
348
+ else finalize(true);
349
+ } catch (ex) { finalize(false); }
350
+ });
351
+
352
+ getRes.on('close', () => { if (!finished) finalize(false); });
353
+ getRes.on('error', (e) => { if (!finished) finalize(false); });
354
+ } catch (ex) { try { return resolveInspect(false); } catch (e) {} }
355
+ });
356
+ }
357
+
358
+ // First do a HEAD to validate content-type/length. Some hosts/CDNs don't
359
+ // support HEAD (returning 405/403/501) even though GET would work. In
360
+ // that case, fall back to a small ranged GET to validate magic bytes.
361
+ doRequest('HEAD', url, { 'User-Agent': 'MeshCentral/validateRemoteImage' }, 5, (err, headRes, finalUrl) => {
362
+ // If doRequest failed to produce a finalUrl (network error),
363
+ // fall back to the original requested URL to allow GET to be
364
+ // attempted when HEAD fails with network/DNS issues.
365
+ const effectiveUrl = finalUrl || url;
366
+ try {
367
+ // If HEAD errored or returned a client/server error
368
+ if (err || (headRes && headRes.statusCode >= 400)) {
369
+ // If the status suggests HEAD is not allowed/implemented or forbidden,
370
+ // attempt a ranged GET fallback. For other 4xx/5xx responses, fail fast.
371
+ const status = headRes && headRes.statusCode ? headRes.statusCode : 0;
372
+ if (err || status === 405 || status === 501 || status === 403) {
373
+ try { if (headRes && headRes.resume) headRes.resume(); } catch (e) {}
374
+ const headers = { 'Range': 'bytes=0-' + (maxHeadBytes - 1), 'User-Agent': 'MeshCentral/validateRemoteImage' };
375
+ // Ranged GET fallback: reuse the same GET inspection logic.
376
+ // Use the effectiveUrl (finalUrl or original url) in case
377
+ // the HEAD redirect wasn't available.
378
+ doRequest('GET', effectiveUrl, headers, 5, (err2, getRes) => {
379
+ try {
380
+ if (err2) return resolve(false);
381
+ if (getRes.statusCode !== 200 && getRes.statusCode !== 206) { getRes.resume(); return resolve(false); }
382
+ inspectStreamForImage(getRes).then((r) => resolve(r)).catch(() => resolve(false));
383
+ } catch (ex) { return resolve(false); }
384
+ });
385
+ return;
386
+ }
387
+ // Other HTTP errors from HEAD should fail fast
388
+ try { if (headRes && headRes.resume) headRes.resume(); } catch (e) {}
389
+ return resolve(false);
390
+ }
391
+ const ct = (headRes.headers['content-type'] || '').toLowerCase();
392
+ const clen = parseInt(headRes.headers['content-length'] || '0', 10) || 0;
393
+ if (clen > 0 && clen > maxContentLength) { headRes.resume(); return resolve(false); }
394
+ if (ct && (allowedMimes.indexOf(ct.split(';')[0].trim()) === -1)) { headRes.resume(); return resolve(false); }
395
+ // Now fetch a partial range to inspect magic bytes
396
+ const headers = { 'Range': 'bytes=0-' + (maxHeadBytes - 1), 'User-Agent': 'MeshCentral/validateRemoteImage' };
397
+ // Drain any potential body from the HEAD response so the
398
+ // socket can be reused and not held open when issuing the GET.
399
+ try { if (headRes && headRes.resume) headRes.resume(); } catch (e) {}
400
+ doRequest('GET', effectiveUrl, headers, 5, (err2, getRes) => {
401
+ try {
402
+ if (err2) return resolve(false);
403
+ if (getRes.statusCode !== 200 && getRes.statusCode !== 206) { getRes.resume(); return resolve(false); }
404
+ inspectStreamForImage(getRes).then((r) => resolve(r)).catch(() => resolve(false));
405
+ } catch (ex) { return resolve(false); }
406
+ });
407
+ } catch (ex) { return resolve(false); }
408
+ });
409
+ } catch (ex) {
410
+ return resolve(false);
411
+ }
412
+ });
413
+}
414
+
415
// Check password requirements
416
module.exports.checkPasswordRequirements = function(password, requirements) {
417
if ((requirements == null) || (requirements == '') || (typeof requirements != 'object')) return true;