mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
647a3221f9
Hash RustDesk access tokens at rest (phase 1), add SSRF guards for admin network tools with LAN monitoring support, run dedicated console service user on Linux, and hook post-update verification plus service patching into both betterdesk.sh and the in-app updater.
155 lines
4.6 KiB
JavaScript
155 lines
4.6 KiB
JavaScript
'use strict';
|
|
|
|
const dns = require('dns').promises;
|
|
const net = require('net');
|
|
|
|
class SsrfBlockedError extends Error {
|
|
constructor(message, code = 'SSRF_BLOCKED') {
|
|
super(message);
|
|
this.name = 'SsrfBlockedError';
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
const BLOCKED_HOSTNAMES = new Set([
|
|
'localhost',
|
|
'localhost.localdomain',
|
|
'metadata.google.internal',
|
|
'metadata.goog',
|
|
]);
|
|
|
|
/**
|
|
* True when the IP must not be reached by server-side outbound probes
|
|
* (RFC1918, loopback, link-local, cloud metadata ranges, etc.).
|
|
*/
|
|
/**
|
|
* @param {string} ip
|
|
* @param {{ allowPrivate?: boolean }} [opts]
|
|
* allowPrivate=true → admin network tools may probe RFC1918 LAN hosts;
|
|
* loopback/link-local/cloud-metadata remain blocked (SSRF to localhost).
|
|
*/
|
|
function isBlockedIp(ip, opts = {}) {
|
|
const allowPrivate = !!opts.allowPrivate;
|
|
if (!ip || typeof ip !== 'string') return true;
|
|
|
|
if (net.isIPv4(ip)) {
|
|
const octets = ip.split('.').map((n) => parseInt(n, 10));
|
|
if (octets.length !== 4 || octets.some((n) => Number.isNaN(n))) return true;
|
|
const [a, b] = octets;
|
|
if (a === 127) return true;
|
|
if (a === 169 && b === 254) return true;
|
|
if (allowPrivate) return false;
|
|
if (a === 0 || a === 10 || a === 127) return true;
|
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
if (a === 192 && b === 168) return true;
|
|
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
return false;
|
|
}
|
|
|
|
if (net.isIPv6(ip)) {
|
|
const normalized = ip.toLowerCase();
|
|
if (normalized === '::1') return true;
|
|
if (normalized.startsWith('fe80:')) return true;
|
|
if (!allowPrivate && (normalized.startsWith('fc') || normalized.startsWith('fd'))) return true;
|
|
if (normalized.startsWith('::ffff:')) {
|
|
const mapped = normalized.slice(7);
|
|
if (net.isIPv4(mapped)) return isBlockedIp(mapped, opts);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function normalizeHostname(host) {
|
|
if (!host || typeof host !== 'string') return '';
|
|
let h = host.trim().toLowerCase();
|
|
if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1);
|
|
return h;
|
|
}
|
|
|
|
function assertSafeHostname(host, opts = {}) {
|
|
const clean = normalizeHostname(host);
|
|
if (!clean) {
|
|
throw new SsrfBlockedError('Missing hostname');
|
|
}
|
|
if (BLOCKED_HOSTNAMES.has(clean)) {
|
|
throw new SsrfBlockedError(`Blocked hostname: ${clean}`);
|
|
}
|
|
if (net.isIP(clean) && isBlockedIp(clean, opts)) {
|
|
throw new SsrfBlockedError(`Blocked IP address: ${clean}`);
|
|
}
|
|
return clean;
|
|
}
|
|
|
|
async function assertSafeResolvedHost(host, opts = {}) {
|
|
const clean = assertSafeHostname(host, opts);
|
|
if (net.isIP(clean)) return clean;
|
|
|
|
let records;
|
|
try {
|
|
records = await dns.lookup(clean, { all: true, verbatim: true });
|
|
} catch (err) {
|
|
throw new SsrfBlockedError(`DNS lookup failed for ${clean}: ${err.message}`);
|
|
}
|
|
|
|
if (!records || records.length === 0) {
|
|
throw new SsrfBlockedError(`No DNS records for ${clean}`);
|
|
}
|
|
|
|
for (const rec of records) {
|
|
if (isBlockedIp(rec.address, opts)) {
|
|
throw new SsrfBlockedError(`Hostname ${clean} resolves to blocked address ${rec.address}`);
|
|
}
|
|
}
|
|
|
|
return clean;
|
|
}
|
|
|
|
/**
|
|
* Validate URL for authenticated admin network tools.
|
|
* Allows RFC1918 LAN targets; still blocks loopback and cloud metadata.
|
|
*/
|
|
async function assertSafeMonitoringUrl(urlString) {
|
|
return assertSafeHttpUrl(urlString, { allowPrivate: true });
|
|
}
|
|
|
|
/**
|
|
* Validate a URL before server-side HTTP fetch (network monitor, etc.).
|
|
* Allows only http/https; blocks private/reserved targets after DNS resolution.
|
|
* @param {string} urlString
|
|
* @param {{ allowPrivate?: boolean }} [opts]
|
|
*/
|
|
async function assertSafeHttpUrl(urlString, opts = {}) {
|
|
if (!urlString || typeof urlString !== 'string') {
|
|
throw new SsrfBlockedError('URL is required');
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(urlString);
|
|
} catch (_) {
|
|
throw new SsrfBlockedError('Invalid URL');
|
|
}
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
throw new SsrfBlockedError('Only http and https URLs are allowed');
|
|
}
|
|
|
|
if (parsed.username || parsed.password) {
|
|
throw new SsrfBlockedError('Credentials in URL are not allowed');
|
|
}
|
|
|
|
await assertSafeResolvedHost(parsed.hostname, opts);
|
|
return parsed;
|
|
}
|
|
|
|
module.exports = {
|
|
SsrfBlockedError,
|
|
isBlockedIp,
|
|
assertSafeHostname,
|
|
assertSafeResolvedHost,
|
|
assertSafeHttpUrl,
|
|
assertSafeMonitoringUrl,
|
|
};
|