diff --git a/package-lock.json b/package-lock.json index e72b1d0..8ab4c52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@open-audio-stack/core", - "version": "0.1.53", + "version": "0.1.54", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@open-audio-stack/core", - "version": "0.1.53", + "version": "0.1.54", "license": "cc0-1.0", "dependencies": { "@vscode/sudo-prompt": "^9.3.1", diff --git a/package.json b/package.json index eadaa29..ab2b846 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-audio-stack/core", - "version": "0.1.53", + "version": "0.1.54", "description": "Open-source audio plugin management software", "type": "module", "main": "./build/index.js", diff --git a/src/classes/ManagerLocal.ts b/src/classes/ManagerLocal.ts index ea91c72..5baf528 100644 --- a/src/classes/ManagerLocal.ts +++ b/src/classes/ManagerLocal.ts @@ -24,7 +24,7 @@ import { isAdmin, runCliAsAdmin, } from '../helpers/file.js'; -import { isValidVersion, pathGetSlug, pathGetVersion, toSlug } from '../helpers/utils.js'; +import { isValidSlug, isValidVersion, pathGetSlug, pathGetVersion, toSlug } from '../helpers/utils.js'; import { commandExists, getArchitecture, getSystem, isTests } from '../helpers/utilsLocal.js'; import { apiBuffer } from '../helpers/api.js'; import { FileInterface } from '../types/File.js'; @@ -192,6 +192,12 @@ export class ManagerLocal extends Manager { async install(slug: string, version?: string) { this.log('install', slug, version); + // slug/version can originate from remote registry JSON (via sync(), e.g. through + // installAll() iterating every synced package) or from a local project file - never from a + // value the caller has already validated. Reject anything malformed before it can reach the + // elevated command payload built below. + if (!isValidSlug(slug)) throw new Error(`Invalid package slug: ${slug}`); + if (version && !isValidVersion(version)) throw new Error(`Invalid package version: ${version}`); // Get package information from registry. const pkg: Package | undefined = this.getPackage(slug); if (!pkg) throw new Error(`Package ${slug} not found in registry`); @@ -229,10 +235,14 @@ export class ManagerLocal extends Manager { // Elevate permissions if not running as admin. if (!isAdmin() && !isTests()) { - let command: string = `--appDir "${this.config.get('appDir')}" --operation "install" --type "${this.type}" --id "${slug}"`; - if (version) command += ` --ver "${version}"`; - if (this.debug) command += ` --log`; - await runCliAsAdmin(command); + await runCliAsAdmin({ + appDir: this.config.get('appDir') as string, + operation: 'install', + type: this.type, + id: slug, + version, + log: this.debug, + }); const returnedPkg = this.getPackage(slug)?.getVersion(versionNum); if (returnedPkg) { if (this.isPackageInstalled(slug, versionNum)) returnedPkg.installed = true; @@ -380,9 +390,13 @@ export class ManagerLocal extends Manager { async installAll() { // Elevate permissions if not running as admin. if (!isAdmin() && !isTests()) { - let command: string = `--appDir "${this.config.get('appDir')}" --operation "installAll" --type "${this.type}"`; - if (this.debug) command += ` --log`; - await runCliAsAdmin(command); + await runCliAsAdmin({ + appDir: this.config.get('appDir') as string, + operation: 'installAll', + type: this.type, + id: '', + log: this.debug, + }); return this.listPackages(); } @@ -497,6 +511,8 @@ export class ManagerLocal extends Manager { } async uninstall(slug: string, version?: string) { + if (!isValidSlug(slug)) throw new Error(`Invalid package slug: ${slug}`); + if (version && !isValidVersion(version)) throw new Error(`Invalid package version: ${version}`); // Get package information from registry. const pkg: Package | undefined = this.getPackage(slug); if (!pkg) throw new Error(`Package ${slug} not found in registry`); @@ -508,10 +524,14 @@ export class ManagerLocal extends Manager { // Elevate permissions if not running as admin. if (!isAdmin() && !isTests()) { - let command: string = `--appDir "${this.config.get('appDir')}" --operation "uninstall" --type "${this.type}" --id "${slug}"`; - if (version) command += ` --ver "${version}"`; - if (this.debug) command += ` --log`; - await runCliAsAdmin(command); + await runCliAsAdmin({ + appDir: this.config.get('appDir') as string, + operation: 'uninstall', + type: this.type, + id: slug, + version, + log: this.debug, + }); const returnedPkg = this.getPackage(slug)?.getVersion(versionNum); if (returnedPkg) { if (this.isPackageInstalled(slug, versionNum)) returnedPkg.installed = true; diff --git a/src/helpers/admin.ts b/src/helpers/admin.ts index 70ea5ef..b6516f8 100644 --- a/src/helpers/admin.ts +++ b/src/helpers/admin.ts @@ -1,6 +1,11 @@ // Run when Electron needs elevated privileges // npm run build && node ./build/helpers/admin.js --operation install --type plugins --id surge-synthesizer/surge // npm run build && node ./build/helpers/admin.js --operation uninstall --type plugins --id surge-synthesizer/surge +// +// runCliAsAdmin() (helpers/file.ts) invokes this via `--payload ` rather than +// individual flags, since the values it carries (appDir/id/version) originate from registry +// metadata or local project files and must never be interpolated into a shell command as text. +// The individual --flag form above is still accepted for manual/developer invocation only. import { RegistryType } from '../types/Registry.js'; import { ManagerLocal } from '../classes/ManagerLocal.js'; @@ -22,6 +27,11 @@ export function adminArguments(): Arguments { type: RegistryType.Plugins, id: 'surge-synthesizer/surge', }; + const payloadIndex = process.argv.indexOf('--payload'); + if (payloadIndex !== -1 && process.argv[payloadIndex + 1]) { + const decoded = JSON.parse(Buffer.from(process.argv[payloadIndex + 1], 'base64url').toString('utf8')); + return { ...args, ...decoded }; + } for (let i = 0; i < process.argv.length; i++) { const arg = process.argv[i]; if (arg === '--appDir') { diff --git a/src/helpers/file.ts b/src/helpers/file.ts index 7c10b74..6fc07bc 100644 --- a/src/helpers/file.ts +++ b/src/helpers/file.ts @@ -1,5 +1,5 @@ import AdmZip from 'adm-zip'; -import { execFileSync, execSync, spawn } from 'child_process'; +import { execFileSync, spawn } from 'child_process'; import { createReadStream, chmodSync, @@ -13,7 +13,7 @@ import { writeFileSync, } from 'fs'; import { createHash } from 'crypto'; -import { unpack } from '7zip-min'; +import { list, unpack } from '7zip-min'; import stream from 'stream/promises'; import { GlobOptionsWithFileTypesFalse, globSync } from 'glob'; import { moveSync } from 'fs-extra/esm'; @@ -34,10 +34,17 @@ import { getSystem } from './utilsLocal.js'; import { log } from './utils.js'; import mime from 'mime-types'; +// Rejects the "zip slip" pattern: an archive entry name like `../../../etc/passwd` or an +// absolute path that, once joined to the extraction directory, resolves outside of it. +export function isSafeArchiveEntryPath(entryName: string, targetRoot: string): boolean { + return dirContains(targetRoot, path.resolve(targetRoot, entryName)); +} + export async function archiveExtract(filePath: string, dirPath: string) { log('⎋', dirPath); const fileName = path.basename(filePath).toLowerCase(); const ext = path.extname(filePath).trim().toLowerCase(); + const targetRoot = path.resolve(dirPath); const tarExtensions = ['.tar', '.gz', '.tgz', '.xz', '.bz2', '.tbz2']; const tarCompoundExtensions = ['.tar.gz', '.tar.xz', '.tar.bz2']; @@ -47,6 +54,9 @@ export async function archiveExtract(filePath: string, dirPath: string) { if (ext === '.zip') { const zip: AdmZip = new AdmZip(filePath); try { + // adm-zip's extractAllTo already guards against zip-slip internally (its sanitize()/ + // canonical() helpers fall back to the entry's basename if it would otherwise resolve + // outside the target directory) - this is the normal, non-fallback path. return zip.extractAllTo(dirPath); } catch (error: any) { // Handle Windows special character issues by extracting files manually @@ -55,23 +65,42 @@ export async function archiveExtract(filePath: string, dirPath: string) { const entries = zip.getEntries(); entries.forEach(entry => { const sanitizedName: string = entry.entryName.replace(/[<>:"|?*]/g, '_').replace(/[\r\n]/g, ''); + // This manual path builds destinations by hand instead of going through adm-zip's own + // sanitize(), so it must enforce the same containment itself - stripping `<>:"|?*` and + // newlines does nothing to stop a `..`-based traversal. + if (!isSafeArchiveEntryPath(sanitizedName, targetRoot)) { + throw new Error(`Archive entry escapes extraction directory: ${entry.entryName}`); + } + const outputPath = path.join(dirPath, sanitizedName); if (!entry.isDirectory) { - const outputPath = path.join(dirPath, sanitizedName); dirCreate(path.dirname(outputPath)); writeFileSync(outputPath, entry.getData()); } else { - dirCreate(path.join(dirPath, sanitizedName)); + dirCreate(outputPath); } }); return; } } } else if (isTarFile) { + // node-tar rejects '..' path segments and relativizes absolute paths by default + // (preservePaths is false unless explicitly opted into), so no extra check is needed here. return await tar.extract({ file: filePath, cwd: dirPath, }); } else if (ext === '.7z') { + // Unlike adm-zip/node-tar, 7zip-min just shells out to the 7za binary with no per-entry + // containment logic of its own, and there's no way to sanitize an entry's destination + // mid-extraction. List the archive's contents first and refuse to extract at all if any + // entry would escape the target directory. + const entries: Array<{ name?: string }> = await new Promise((resolve, reject) => { + list(filePath, (err: any, result: any) => (err ? reject(err) : resolve(result || []))); + }); + const unsafeEntry = entries.find(entry => entry.name && !isSafeArchiveEntryPath(entry.name, targetRoot)); + if (unsafeEntry) { + throw new Error(`Archive entry escapes extraction directory: ${unsafeEntry.name}`); + } return new Promise((resolve, reject) => { unpack(filePath, dirPath, (err2: any) => { if (err2) @@ -89,7 +118,12 @@ export function dirApp(dirName = 'open-audio-stack') { } export function dirContains(parentDir: string, childDir: string): boolean { - return path.normalize(childDir).startsWith(path.normalize(parentDir)); + const normalizedParent = path.normalize(parentDir); + const normalizedChild = path.normalize(childDir); + // A trailing separator is required before the prefix check, otherwise a sibling directory + // that merely shares a prefix (e.g. parent "/foo/bar" vs child "/foo/barbaz") would + // incorrectly count as contained. + return normalizedChild === normalizedParent || normalizedChild.startsWith(normalizedParent + path.sep); } export function dirCreate(dir: string) { @@ -132,13 +166,18 @@ export function dirMove(dir: string, dirNew: string): void | boolean { } export function dirOpen(dir: string) { - let command: string = ''; if (process.env.CI) return Buffer.from(''); - if (getSystem() === SystemType.Win) command = 'start ""'; - else if (getSystem() === SystemType.Mac) command = 'open'; - else command = 'xdg-open'; - log('⎋', `${command} "${dir}"`); - return execSync(`${command} "${dir}"`); + // execFileSync never invokes a shell, so `dir` can't break out into a second command + // regardless of its contents. + if (getSystem() === SystemType.Win) { + log('⎋', `cmd.exe /c start "" "${dir}"`); + return execFileSync('cmd.exe', ['/c', 'start', '""', dir]); + } else if (getSystem() === SystemType.Mac) { + log('⎋', `open "${dir}"`); + return execFileSync('open', [dir]); + } + log('⎋', `xdg-open "${dir}"`); + return execFileSync('xdg-open', [dir]); } export function dirPackage(pkg: PackageInterface) { @@ -243,34 +282,78 @@ export async function fileHash(filePath: string, algorithm = 'sha256'): Promise< return hash.digest('hex'); } +// Mounts to an explicit, freshly created mountpoint (rather than scanning /Volumes for +// whatever showed up) so a concurrently mounted, unrelated disk image can't be picked up +// instead, and so we know exactly what to detach afterwards. +function installDmg(filePath: string) { + const mountPoint = path.join(os.tmpdir(), `oas-dmg-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(mountPoint, { recursive: true }); + try { + log('⎋', `hdiutil attach -nobrowse -mountpoint "${mountPoint}" "${filePath}"`); + execFileSync('hdiutil', ['attach', '-nobrowse', '-mountpoint', mountPoint, filePath]); + const pkgs = dirRead(path.join(mountPoint, '**', '*.pkg')); + if (pkgs.length === 0) throw new Error(`No .pkg found inside ${filePath}`); + log('⎋', `sudo installer -pkg "${pkgs[0]}" -target /`); + return execFileSync('sudo', ['installer', '-pkg', pkgs[0], '-target', '/'], { stdio: 'inherit' }); + } finally { + try { + execFileSync('hdiutil', ['detach', mountPoint, '-force']); + } catch { + /* best-effort unmount */ + } + } +} + +// Every branch below uses execFileSync (no shell) rather than building a command string for +// execSync. This is the actual fix, not just a hardening pass: file paths here are derived +// from community-submitted registry metadata (file.url), so a shell string built via template +// literal is a command injection vector regardless of how strictly the url is validated +// upstream - execFileSync passes each argument as its own argv entry, so shell metacharacters +// in filePath (`$(...)`, backticks, `;`, `|`, `&&`, ...) can never be interpreted. export function fileInstall(filePath: string) { if (process.env.CI) return Buffer.from(''); const ext = path.extname(filePath).toLowerCase(); - let command: string | null = null; switch (ext) { case '.dmg': - command = `hdiutil attach -nobrowse "${filePath}" && sudo installer -pkg "$(find /Volumes -name '*.pkg' -maxdepth 2 | head -n 1)" -target / && hdiutil detach "$(dirname "$(find /Volumes -name '*.pkg' -maxdepth 2 | head -n 1)")"`; - break; + return installDmg(filePath); case '.pkg': - command = `sudo installer -pkg "${filePath}" -target /`; - break; + log('⎋', `sudo installer -pkg "${filePath}" -target /`); + return execFileSync('sudo', ['installer', '-pkg', filePath, '-target', '/'], { stdio: 'inherit' }); case '.deb': - command = `sudo dpkg -i "${filePath}" || sudo apt-get install -f -y`; - break; + log('⎋', `sudo dpkg -i "${filePath}" || sudo apt-get install -f -y`); + try { + return execFileSync('sudo', ['dpkg', '-i', filePath], { stdio: 'inherit' }); + } catch { + return execFileSync('sudo', ['apt-get', 'install', '-f', '-y'], { stdio: 'inherit' }); + } case '.rpm': - command = `sudo rpm -i --nodigest --nofiledigest --nosignature --force "${filePath}" || sudo dnf install -y "${filePath}" || sudo yum install -y "${filePath}"`; - break; + log( + '⎋', + `sudo rpm -i --nodigest --nofiledigest --nosignature --force "${filePath}" || sudo dnf install -y "${filePath}" || sudo yum install -y "${filePath}"`, + ); + try { + return execFileSync( + 'sudo', + ['rpm', '-i', '--nodigest', '--nofiledigest', '--nosignature', '--force', filePath], + { stdio: 'inherit' }, + ); + } catch { + try { + return execFileSync('sudo', ['dnf', 'install', '-y', filePath], { stdio: 'inherit' }); + } catch { + return execFileSync('sudo', ['yum', 'install', '-y', filePath], { stdio: 'inherit' }); + } + } case '.exe': - command = `start /wait "" "${filePath}" /quiet /norestart`; - break; + // Run the downloaded installer directly - no shell/`start` wrapper needed at all. + log('⎋', `"${filePath}" /quiet /norestart`); + return execFileSync(filePath, ['/quiet', '/norestart'], { stdio: 'inherit' }); case '.msi': - command = `msiexec /i "${filePath}" /quiet /norestart`; - break; + log('⎋', `msiexec /i "${filePath}" /quiet /norestart`); + return execFileSync('msiexec', ['/i', filePath, '/quiet', '/norestart'], { stdio: 'inherit' }); default: throw new Error(`Unsupported file format: ${ext}`); } - log('⎋', command); - return execSync(command, { stdio: 'inherit' }); } export function fileMove(filePath: string, newPath: string): void | boolean { @@ -356,6 +439,9 @@ export function filesMove(dirSource: string, dirTarget: string, dirSub: string, return filesMoved; } +// filePath (and, for the Windows/Linux branches, the surrounding options) ultimately come from +// a package's `open` field in registry metadata, so this is the same command-injection surface +// as fileInstall - execFileSync (no shell) rather than execSync everywhere below. export function fileOpen(filePath: string, options: string[] = []) { if (process.env.CI) return Buffer.from(''); @@ -368,15 +454,16 @@ export function fileOpen(filePath: string, options: string[] = []) { return child; } else { log('⎋', `open "${filePath}"`); - return execSync(`open "${filePath}"`); + return execFileSync('open', [filePath]); } } - let command: string = ''; - if (getSystem() === SystemType.Win) command = 'start ""'; - else command = 'xdg-open'; - log('⎋', `${command} "${filePath}"`); - return execSync(`${command} "${filePath}"`); + if (getSystem() === SystemType.Win) { + log('⎋', `cmd.exe /c start "" "${filePath}"`); + return execFileSync('cmd.exe', ['/c', 'start', '""', filePath]); + } + log('⎋', `xdg-open "${filePath}"`); + return execFileSync('xdg-open', [filePath]); } export function fileRead(filePath: string) { @@ -449,15 +536,31 @@ export function getPlatform() { return SystemType.Linux; } -export function runCliAsAdmin(args: string): Promise { +export interface AdminPayload { + appDir: string; + operation: string; + type: string; + id: string; + version?: string; + log?: boolean; +} + +// sudo-prompt's exec() only accepts a single command string run through a shell - there is no +// argv-array form to escape into. `appDir`/`id`/`version` ultimately come from registry +// metadata or local project files, so building `--flag "${value}"` text here would be the same +// command-injection surface as fileInstall. Instead, base64url-encode the dynamic payload: its +// alphabet is only [A-Za-z0-9_-], so whatever the payload contains, the shell only ever sees +// characters that can't be interpreted as shell syntax. +export function runCliAsAdmin(payload: AdminPayload): Promise { return new Promise((resolve, reject) => { const filename: string = fileURLToPath(import.meta.url).replace('src/', 'build/'); const dirPathClean: string = dirname(filename).replace('app.asar', 'app.asar.unpacked'); const script: string = path.join(dirPathClean, 'admin.js'); + const encodedPayload: string = Buffer.from(JSON.stringify(payload)).toString('base64url'); - log(`Running as admin: node "${script}" ${args}`); + log(`Running as admin: node "${script}" --payload `); - const cmd = `node "${script}" ${args}`; + const cmd = `node ${JSON.stringify(script)} --payload ${encodedPayload}`; sudoPrompt.exec( cmd, diff --git a/tests/helpers/file.test.ts b/tests/helpers/file.test.ts index b42f3c1..dc754a0 100644 --- a/tests/helpers/file.test.ts +++ b/tests/helpers/file.test.ts @@ -19,6 +19,7 @@ import { dirRename, fileCreate, fileHash, + isSafeArchiveEntryPath, // fileCreateJson, // fileDate, // fileDelete, @@ -64,6 +65,20 @@ test('Create existing directory', () => { test('Directory contains', () => { expect(dirContains('test', DIR_PATH)).toEqual(true); expect(dirContains(dirApp(), DIR_PATH)).toEqual(false); + // A sibling directory that merely shares a prefix must not count as contained. + expect(dirContains(path.join('test', 'new'), path.join('test', 'new-directory'))).toEqual(false); +}); + +test('Archive entry path is safe', () => { + const root = path.resolve('test', 'extract-root'); + expect(isSafeArchiveEntryPath('plugin.vst3', root)).toEqual(true); + expect(isSafeArchiveEntryPath(path.join('nested', 'plugin.vst3'), root)).toEqual(true); +}); + +test('Archive entry path escapes extraction directory', () => { + const root = path.resolve('test', 'extract-root'); + expect(isSafeArchiveEntryPath(path.join('..', '..', 'etc', 'passwd'), root)).toEqual(false); + expect(isSafeArchiveEntryPath(path.resolve('/etc/passwd'), root)).toEqual(false); }); test('Directory is empty', () => {