Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions ui/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion ui/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
137 changes: 109 additions & 28 deletions ui/desktop/src/utils/githubUpdater.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<void>((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');
Expand Down Expand Up @@ -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<void> {
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();
Loading