mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
aea7b30b29
Updated CodeQL configuration to exclude additional paths for security checks. Introduced a new file access rate limiter to prevent abuse of file download endpoints. Improved path resolution functions to ensure confined access and added validation in body scalar functions to reject non-scalar types. Enhanced error handling in API endpoint validation to prevent invalid inputs.
234 lines
7.1 KiB
JavaScript
234 lines
7.1 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
/**
|
|
* True when resolvedPath is rootDir or a descendant (no .. escape).
|
|
*/
|
|
function isPathInsideRoot(resolvedPath, rootDir) {
|
|
const root = path.resolve(rootDir);
|
|
const target = path.resolve(resolvedPath);
|
|
const rel = path.relative(root, target);
|
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
}
|
|
|
|
/**
|
|
* Resolve a single path segment under rootDir (no slashes in childName).
|
|
*/
|
|
function resolveChildPath(rootDir, childName) {
|
|
if (typeof childName !== 'string' || childName.length === 0) {
|
|
throw new Error('Invalid path segment');
|
|
}
|
|
if (childName.includes('\0') || childName.includes('/') || childName.includes('\\')) {
|
|
throw new Error('Invalid path segment');
|
|
}
|
|
if (childName === '.' || childName === '..') {
|
|
throw new Error('Invalid path segment');
|
|
}
|
|
const root = path.resolve(rootDir);
|
|
const target = path.resolve(root, childName);
|
|
if (!isPathInsideRoot(target, root)) {
|
|
throw new Error('Path outside allowed directory');
|
|
}
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* Resolve userPath and ensure it stays within rootDir.
|
|
*/
|
|
function resolvePathWithinRoot(userPath, rootDir) {
|
|
if (typeof userPath !== 'string' || userPath.length === 0) {
|
|
throw new Error('Path is required');
|
|
}
|
|
if (userPath.includes('\0')) {
|
|
throw new Error('Invalid path');
|
|
}
|
|
const root = path.resolve(rootDir);
|
|
const abs = path.resolve(userPath);
|
|
if (!isPathInsideRoot(abs, root)) {
|
|
throw new Error('Path outside allowed directory');
|
|
}
|
|
if (fs.existsSync(abs)) {
|
|
const real = fs.realpathSync.native(abs);
|
|
if (!isPathInsideRoot(real, root)) {
|
|
throw new Error('Path resolves outside allowed directory');
|
|
}
|
|
return real;
|
|
}
|
|
return abs;
|
|
}
|
|
|
|
/**
|
|
* Resolve userPath within the first matching allowed root.
|
|
*/
|
|
function resolvePathWithinAnyRoot(userPath, roots) {
|
|
if (typeof userPath !== 'string' || userPath.length === 0) {
|
|
throw new Error('Path is required');
|
|
}
|
|
if (userPath.includes('\0')) {
|
|
throw new Error('Invalid path');
|
|
}
|
|
const abs = path.resolve(userPath);
|
|
const normalizedRoots = roots.map((r) => path.resolve(r));
|
|
const matched = normalizedRoots.some((root) => isPathInsideRoot(abs, root));
|
|
if (!matched) {
|
|
throw new Error('Path outside allowed directory roots');
|
|
}
|
|
if (fs.existsSync(abs)) {
|
|
const real = fs.realpathSync.native(abs);
|
|
const realMatched = normalizedRoots.some((root) => isPathInsideRoot(real, root));
|
|
if (!realMatched) {
|
|
throw new Error('Path resolves outside allowed directory roots');
|
|
}
|
|
return real;
|
|
}
|
|
return abs;
|
|
}
|
|
|
|
/**
|
|
* Language JSON file under langDir (BCP 47 code validated by caller).
|
|
*/
|
|
function resolveLangFilePath(langDir, code) {
|
|
const root = path.resolve(langDir);
|
|
return resolveChildPath(root, `${code}.json`);
|
|
}
|
|
|
|
function readLangFileText(langDir, code) {
|
|
const filePath = resolveLangFilePath(langDir, code);
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
}
|
|
|
|
function langFileExists(langDir, code) {
|
|
const filePath = resolveLangFilePath(langDir, code);
|
|
return fs.existsSync(filePath);
|
|
}
|
|
|
|
/**
|
|
* Resolve a relative path (may contain slashes) under rootDir.
|
|
*/
|
|
function resolvePathUnderRoot(rootDir, relativePath) {
|
|
if (typeof relativePath !== 'string' || relativePath.length === 0) {
|
|
throw new Error('Relative path is required');
|
|
}
|
|
if (relativePath.includes('\0')) {
|
|
throw new Error('Invalid relative path');
|
|
}
|
|
const normalized = relativePath.replace(/\\/g, '/');
|
|
if (path.isAbsolute(normalized) || normalized.startsWith('/')) {
|
|
throw new Error('Invalid relative path');
|
|
}
|
|
const segments = normalized.split('/').filter((s) => s.length > 0);
|
|
let current = path.resolve(rootDir);
|
|
for (const segment of segments) {
|
|
current = resolveChildPath(current, segment);
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Confined filesystem helpers — validation and I/O in one step so paths
|
|
* never leave the allowed root between check and use.
|
|
*/
|
|
function existsConfinedChild(rootDir, childName) {
|
|
const root = path.resolve(rootDir);
|
|
const target = resolveChildPath(root, childName);
|
|
return fs.existsSync(target);
|
|
}
|
|
|
|
function removeConfinedChild(rootDir, childName, options = { recursive: true, force: true }) {
|
|
const root = path.resolve(rootDir);
|
|
const target = resolveChildPath(root, childName);
|
|
if (target === root) {
|
|
throw new Error('Refusing to delete the root directory');
|
|
}
|
|
if (!fs.existsSync(target)) {
|
|
throw new Error('Path not found');
|
|
}
|
|
fs.rmSync(target, options);
|
|
return target;
|
|
}
|
|
|
|
function readTextConfinedWithinRoot(userPath, rootDir) {
|
|
const abs = finalizedConfinedPathWithinRoot(userPath, rootDir);
|
|
return fs.readFileSync(abs, 'utf8');
|
|
}
|
|
|
|
function existsConfinedWithinRoot(userPath, rootDir) {
|
|
const abs = finalizedConfinedPathWithinRoot(userPath, rootDir);
|
|
return fs.existsSync(abs);
|
|
}
|
|
|
|
/**
|
|
* Resolve and finalize a confined path (realpath when present) before fs I/O.
|
|
*/
|
|
function finalizedConfinedPath(userPath, roots) {
|
|
const abs = resolvePathWithinAnyRoot(userPath, roots);
|
|
const resolvedRoots = roots.map((r) => path.resolve(r));
|
|
if (fs.existsSync(abs)) {
|
|
const real = fs.realpathSync.native(abs);
|
|
if (!resolvedRoots.some((root) => isPathInsideRoot(real, root))) {
|
|
throw new Error('Path resolves outside allowed directory roots');
|
|
}
|
|
return real;
|
|
}
|
|
return abs;
|
|
}
|
|
|
|
function finalizedConfinedPathWithinRoot(userPath, rootDir) {
|
|
const abs = resolvePathWithinRoot(userPath, rootDir);
|
|
const root = path.resolve(rootDir);
|
|
if (fs.existsSync(abs)) {
|
|
const real = fs.realpathSync.native(abs);
|
|
if (!isPathInsideRoot(real, root)) {
|
|
throw new Error('Path resolves outside allowed directory');
|
|
}
|
|
return real;
|
|
}
|
|
return abs;
|
|
}
|
|
|
|
function renameConfinedWithinRoots(oldPath, newPath, roots) {
|
|
const a = finalizedConfinedPath(oldPath, roots);
|
|
const b = finalizedConfinedPath(newPath, roots);
|
|
fs.renameSync(a, b);
|
|
return { from: a, to: b };
|
|
}
|
|
|
|
function unlinkConfinedWithinRoots(userPath, roots) {
|
|
const abs = finalizedConfinedPath(userPath, roots);
|
|
fs.unlinkSync(abs);
|
|
return abs;
|
|
}
|
|
|
|
function mkdirConfinedWithinRoots(userPath, roots) {
|
|
const abs = finalizedConfinedPath(userPath, roots);
|
|
fs.mkdirSync(abs, { recursive: false });
|
|
return abs;
|
|
}
|
|
|
|
function rmDirConfinedWithinRoots(userPath, roots) {
|
|
const abs = finalizedConfinedPath(userPath, roots);
|
|
fs.rmSync(abs, { recursive: true, force: false });
|
|
return abs;
|
|
}
|
|
|
|
module.exports = {
|
|
isPathInsideRoot,
|
|
resolveChildPath,
|
|
resolvePathWithinRoot,
|
|
resolvePathWithinAnyRoot,
|
|
resolveLangFilePath,
|
|
resolvePathUnderRoot,
|
|
readLangFileText,
|
|
langFileExists,
|
|
existsConfinedChild,
|
|
removeConfinedChild,
|
|
readTextConfinedWithinRoot,
|
|
existsConfinedWithinRoot,
|
|
renameConfinedWithinRoots,
|
|
unlinkConfinedWithinRoots,
|
|
mkdirConfinedWithinRoots,
|
|
rmDirConfinedWithinRoots,
|
|
};
|