From 6d954bcbf49862029754f0f1cf95bde576ffdc90 Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Fri, 13 Jun 2025 13:36:36 -0700 Subject: [PATCH] Change updater to use platform agnostic and secure zip library (#2913) --- ui/desktop/package-lock.json | 33 ++++--- ui/desktop/package.json | 4 +- ui/desktop/src/utils/githubUpdater.ts | 137 ++++++++++++++++++++------ 3 files changed, 133 insertions(+), 41 deletions(-) diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 27ccf095..89d02a6d 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -27,6 +27,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@types/react-syntax-highlighter": "^15.5.13", + "@types/yauzl": "^2.10.3", "ai": "^3.4.33", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -53,7 +54,8 @@ "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", "unist-util-visit": "^5.0.0", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "yauzl": "^3.0.0" }, "devDependencies": { "@electron-forge/cli": "^7.5.0", @@ -4314,7 +4316,6 @@ "version": "22.15.28", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.28.tgz", "integrity": "sha512-I0okKVDmyKR281I0UIFV7EWAWRnR0gkuSKob5wVcByyyhr7Px/slhkQapcYX4u00ekzNWaS1gznKZnuzxwo4pw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4433,9 +4434,7 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "@types/node": "*" } @@ -5551,7 +5550,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -8219,6 +8217,17 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/extract-zip/node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -13002,7 +13011,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, "license": "MIT" }, "node_modules/perfect-debounce": { @@ -16232,7 +16240,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -17259,14 +17266,16 @@ } }, "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", + "integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==", "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/yocto-queue": { diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 2646f2b3..4e3906a7 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -101,6 +101,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@types/react-syntax-highlighter": "^15.5.13", + "@types/yauzl": "^2.10.3", "ai": "^3.4.33", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -127,6 +128,7 @@ "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", "unist-util-visit": "^5.0.0", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "yauzl": "^3.0.0" } } diff --git a/ui/desktop/src/utils/githubUpdater.ts b/ui/desktop/src/utils/githubUpdater.ts index 28dbaf3e..b4bb2d7f 100644 --- a/ui/desktop/src/utils/githubUpdater.ts +++ b/ui/desktop/src/utils/githubUpdater.ts @@ -1,9 +1,10 @@ import { app } from 'electron'; import { compareVersions } from 'compare-versions'; import * as fs from 'fs/promises'; +import { createWriteStream } from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { spawn } from 'child_process'; +import * as yauzl from 'yauzl'; import log from './logger'; interface GitHubRelease { @@ -190,40 +191,17 @@ export class GitHubUpdater { log.info(`GitHubUpdater: Update downloaded to ${downloadPath}`); - // Auto-unzip the downloaded file + // Auto-unzip the downloaded file using yauzl (secure ZIP library) try { const tempExtractDir = path.join(downloadsDir, `temp-extract-${Date.now()}`); // Create temp extraction directory await fs.mkdir(tempExtractDir, { recursive: true }); - // Use unzip command to extract - log.info(`GitHubUpdater: Extracting ${fileName} to temp directory`); + log.info(`GitHubUpdater: Extracting ${fileName} to temp directory using yauzl`); - const unzipProcess = spawn('unzip', ['-o', downloadPath, '-d', tempExtractDir]); - - let stderr = ''; - unzipProcess.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - await new Promise((resolve, reject) => { - unzipProcess.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`Unzip process exited with code ${code}`)); - } - }); - - unzipProcess.on('error', (err) => { - reject(err); - }); - }); - - if (stderr && !stderr.includes('warning')) { - log.warn(`GitHubUpdater: Unzip stderr: ${stderr}`); - } + // Use yauzl to extract the ZIP file securely + await extractZipFile(downloadPath, tempExtractDir); // Check if Goose.app exists in the extracted content const appPath = path.join(tempExtractDir, 'Goose.app'); @@ -287,5 +265,108 @@ export class GitHubUpdater { } } +/** + * Securely extract a ZIP file using yauzl with security checks + * @param zipPath Path to the ZIP file + * @param extractDir Directory to extract to + */ +async function extractZipFile(zipPath: string, extractDir: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => { + if (err) { + reject(err); + return; + } + + if (!zipfile) { + reject(new Error('Failed to open ZIP file')); + return; + } + + zipfile.readEntry(); + + zipfile.on('entry', async (entry: yauzl.Entry) => { + try { + // Security check: prevent directory traversal attacks + if (entry.fileName.includes('..') || path.isAbsolute(entry.fileName)) { + log.warn(`GitHubUpdater: Skipping potentially dangerous path: ${entry.fileName}`); + zipfile.readEntry(); + return; + } + + const fullPath = path.join(extractDir, entry.fileName); + + // Ensure the resolved path is still within the extraction directory + const resolvedPath = path.resolve(fullPath); + const resolvedExtractDir = path.resolve(extractDir); + if (!resolvedPath.startsWith(resolvedExtractDir + path.sep)) { + log.warn(`GitHubUpdater: Path traversal attempt detected: ${entry.fileName}`); + zipfile.readEntry(); + return; + } + + // Handle directories + if (entry.fileName.endsWith('/')) { + await fs.mkdir(fullPath, { recursive: true }); + zipfile.readEntry(); + return; + } + + // Handle files + zipfile.openReadStream(entry, async (err, readStream) => { + if (err) { + reject(err); + return; + } + + if (!readStream) { + reject(new Error('Failed to open read stream')); + return; + } + + try { + // Ensure parent directory exists + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + + // Create write stream + const writeStream = createWriteStream(fullPath); + + readStream.on('end', () => { + writeStream.end(); + zipfile.readEntry(); + }); + + readStream.on('error', (streamErr) => { + writeStream.destroy(); + reject(streamErr); + }); + + writeStream.on('error', (writeErr: Error) => { + reject(writeErr); + }); + + // Pipe the data + readStream.pipe(writeStream); + } catch (fileErr) { + reject(fileErr); + } + }); + } catch (entryErr) { + reject(entryErr); + } + }); + + zipfile.on('end', () => { + log.info('GitHubUpdater: ZIP extraction completed successfully'); + resolve(); + }); + + zipfile.on('error', (zipErr) => { + reject(zipErr); + }); + }); + }); +} + // Create singleton instance export const githubUpdater = new GitHubUpdater();