mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
c219f3be68
Complete GHSA-3v82-3gf8-fxx8 WebSocket replay fix for soft-deleted peers, bump vulnerable Go/Rust dependencies, and apply CodeQL-driven hardening (OIDC URL validation, path confinement, execFileSync, rate limits).
244 lines
7.9 KiB
JavaScript
244 lines
7.9 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execSync, execFileSync } = require('child_process');
|
|
|
|
const SERVER_BINARY_NAME = 'betterdesk-server';
|
|
const ALLOWED_TARGET_DIRS = [
|
|
'/opt/rustdesk',
|
|
'/opt/betterdesk',
|
|
'/usr/local/bin',
|
|
];
|
|
|
|
function resolveDeployScriptPath(consoleRoot) {
|
|
return path.join(consoleRoot, 'scripts/linux-deploy-server-binary.js');
|
|
}
|
|
|
|
function resolveServerSourceRoot(consoleRoot, projectRoot) {
|
|
const flat = path.join(consoleRoot, 'betterdesk-server');
|
|
if (fs.existsSync(path.join(flat, 'go.mod'))) {
|
|
return flat;
|
|
}
|
|
return path.join(projectRoot, 'betterdesk-server');
|
|
}
|
|
|
|
function normalizeDeployPath(value) {
|
|
if (!value || typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!path.isAbsolute(trimmed)) return null;
|
|
return path.resolve(trimmed);
|
|
}
|
|
|
|
function resolveRealFile(filePath) {
|
|
const normalized = normalizeDeployPath(filePath);
|
|
if (!normalized || !fs.existsSync(normalized)) {
|
|
throw new Error(`Source file not found: ${filePath}`);
|
|
}
|
|
const real = fs.realpathSync(normalized);
|
|
if (!fs.statSync(real).isFile()) {
|
|
throw new Error(`Source is not a regular file: ${filePath}`);
|
|
}
|
|
return real;
|
|
}
|
|
|
|
function buildAllowedDeployTargets(options = {}) {
|
|
const targets = new Set();
|
|
for (const dir of ALLOWED_TARGET_DIRS) {
|
|
targets.add(path.join(dir, SERVER_BINARY_NAME));
|
|
}
|
|
|
|
const keysPath = options.keysPath || options.rustdeskDir;
|
|
if (keysPath) {
|
|
targets.add(path.join(path.resolve(keysPath), SERVER_BINARY_NAME));
|
|
}
|
|
|
|
const fromEnv = process.env.BETTERDESK_SERVER_BINARY;
|
|
if (fromEnv) {
|
|
const normalized = normalizeDeployPath(fromEnv);
|
|
if (normalized) targets.add(normalized);
|
|
}
|
|
|
|
if (options.extraTarget) {
|
|
const normalized = normalizeDeployPath(options.extraTarget);
|
|
if (normalized) targets.add(normalized);
|
|
}
|
|
|
|
if (options.projectRoot) {
|
|
targets.add(path.join(path.resolve(options.projectRoot), 'betterdesk-server', SERVER_BINARY_NAME));
|
|
}
|
|
|
|
if (options.consoleRoot) {
|
|
targets.add(path.join(path.resolve(options.consoleRoot), 'betterdesk-server', SERVER_BINARY_NAME));
|
|
}
|
|
|
|
return targets;
|
|
}
|
|
|
|
function isPathInside(parent, child) {
|
|
const relative = path.relative(parent, child);
|
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function validateDeployRequest(sourcePath, targetPath, options = {}) {
|
|
const consoleRoot = path.resolve(options.consoleRoot || path.join(__dirname, '..'));
|
|
const projectRoot = path.resolve(options.projectRoot || path.join(consoleRoot, '..'));
|
|
const serverSourceRoot = path.resolve(
|
|
options.serverSourceRoot || resolveServerSourceRoot(consoleRoot, projectRoot)
|
|
);
|
|
|
|
const sourceReal = resolveRealFile(sourcePath);
|
|
const serverRootReal = fs.realpathSync(serverSourceRoot);
|
|
if (!isPathInside(serverRootReal, sourceReal)) {
|
|
throw new Error(`Source must be under ${serverRootReal}`);
|
|
}
|
|
if (path.basename(sourceReal) !== SERVER_BINARY_NAME) {
|
|
throw new Error(`Source must be named ${SERVER_BINARY_NAME}`);
|
|
}
|
|
|
|
const stat = fs.statSync(sourceReal);
|
|
if (stat.size < 512 * 1024) {
|
|
throw new Error('Source binary is unexpectedly small');
|
|
}
|
|
|
|
const targetNormalized = normalizeDeployPath(targetPath);
|
|
if (!targetNormalized) {
|
|
throw new Error('Target path must be absolute');
|
|
}
|
|
if (path.basename(targetNormalized) !== SERVER_BINARY_NAME) {
|
|
throw new Error(`Target must be named ${SERVER_BINARY_NAME}`);
|
|
}
|
|
|
|
const allowedTargets = buildAllowedDeployTargets({
|
|
keysPath: options.keysPath,
|
|
rustdeskDir: options.rustdeskDir,
|
|
extraTarget: options.extraTarget,
|
|
projectRoot: options.projectRoot,
|
|
consoleRoot: options.consoleRoot,
|
|
});
|
|
const targetReal = fs.existsSync(targetNormalized)
|
|
? fs.realpathSync(targetNormalized)
|
|
: targetNormalized;
|
|
|
|
let allowed = false;
|
|
for (const candidate of allowedTargets) {
|
|
const candidateReal = fs.existsSync(candidate)
|
|
? fs.realpathSync(candidate)
|
|
: path.resolve(candidate);
|
|
if (candidateReal === targetReal || candidateReal === targetNormalized) {
|
|
allowed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!allowed) {
|
|
throw new Error(`Target path is not allowed: ${targetNormalized}`);
|
|
}
|
|
|
|
return {
|
|
sourceReal,
|
|
targetPath: targetNormalized,
|
|
serverSourceRoot: serverRootReal,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Atomic replace for a running Linux executable (rename over directory entry).
|
|
* @returns {{ success: boolean, backupPath?: string|null, error?: string }}
|
|
*/
|
|
function deployServerBinaryAtomic(sourceReal, targetPath) {
|
|
let backupPath = null;
|
|
if (fs.existsSync(targetPath)) {
|
|
backupPath = `${targetPath}.bak.${Date.now()}`;
|
|
try {
|
|
fs.copyFileSync(targetPath, backupPath);
|
|
} catch (err) {
|
|
return { success: false, error: `Backup failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
const stagingPath = `${targetPath}.new.${process.pid}.${Date.now()}`;
|
|
try {
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.copyFileSync(sourceReal, stagingPath);
|
|
try { fs.chmodSync(stagingPath, 0o755); } catch (_e) { /* ok */ }
|
|
|
|
try {
|
|
fs.renameSync(stagingPath, targetPath);
|
|
} catch (renameErr) {
|
|
try {
|
|
fs.copyFileSync(stagingPath, targetPath);
|
|
try { fs.unlinkSync(stagingPath); } catch (_e) { /* ok */ }
|
|
try { fs.chmodSync(targetPath, 0o755); } catch (_e) { /* ok */ }
|
|
} catch (copyErr) {
|
|
throw renameErr.code === 'ETXTBSY' ? renameErr : copyErr;
|
|
}
|
|
}
|
|
return { success: true, backupPath };
|
|
} catch (err) {
|
|
try { if (fs.existsSync(stagingPath)) fs.unlinkSync(stagingPath); } catch (_e) { /* ok */ }
|
|
if (backupPath && fs.existsSync(backupPath) && !fs.existsSync(targetPath)) {
|
|
try { fs.copyFileSync(backupPath, targetPath); } catch (_e) { /* critical */ }
|
|
}
|
|
return { success: false, error: `Deploy failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
function canWriteDirectory(dirPath) {
|
|
try {
|
|
fs.accessSync(dirPath, fs.constants.W_OK);
|
|
return true;
|
|
} catch (_e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function canUsePrivilegedDeploy(scriptPath) {
|
|
if (process.platform !== 'linux' || !scriptPath || !fs.existsSync(scriptPath)) {
|
|
return false;
|
|
}
|
|
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
return true;
|
|
}
|
|
try {
|
|
execFileSync('sudo', ['-n', scriptPath, '--check'], {
|
|
timeout: 5000,
|
|
stdio: 'pipe',
|
|
});
|
|
return true;
|
|
} catch (_e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {{ ready: boolean, method?: 'direct'|'privileged', scriptPath?: string, targetDir?: string }}
|
|
*/
|
|
function assessServerBinaryDeployCapability(targetPath, options = {}) {
|
|
const normalized = normalizeDeployPath(targetPath);
|
|
if (!normalized) {
|
|
return { ready: false };
|
|
}
|
|
const targetDir = path.dirname(normalized);
|
|
if (canWriteDirectory(targetDir)) {
|
|
return { ready: true, method: 'direct', targetDir };
|
|
}
|
|
const consoleRoot = options.consoleRoot || path.join(__dirname, '..');
|
|
const scriptPath = resolveDeployScriptPath(consoleRoot);
|
|
if (canUsePrivilegedDeploy(scriptPath)) {
|
|
return { ready: true, method: 'privileged', scriptPath, targetDir };
|
|
}
|
|
return { ready: false, scriptPath, targetDir };
|
|
}
|
|
|
|
module.exports = {
|
|
SERVER_BINARY_NAME,
|
|
ALLOWED_TARGET_DIRS,
|
|
resolveDeployScriptPath,
|
|
resolveServerSourceRoot,
|
|
buildAllowedDeployTargets,
|
|
validateDeployRequest,
|
|
deployServerBinaryAtomic,
|
|
assessServerBinaryDeployCapability,
|
|
canUsePrivilegedDeploy,
|
|
};
|