diff --git a/packages/cli/src/utils/standalone-update.test.ts b/packages/cli/src/utils/standalone-update.test.ts index b59552b37e2..fa71158e82e 100644 --- a/packages/cli/src/utils/standalone-update.test.ts +++ b/packages/cli/src/utils/standalone-update.test.ts @@ -13,6 +13,7 @@ import { ensureBinWrapper, ensurePathInShellRc, performStandaloneUpdate, + isSafeTarEntryPath, } from './standalone-update.js'; describe('standalone-update', () => { @@ -260,6 +261,25 @@ describe('standalone-update', () => { }); }); + describe('isSafeTarEntryPath', () => { + it('allows double dots inside a filename segment', () => { + expect(isSafeTarEntryPath('qwen-code/release..notes.md')).toBe(true); + expect(isSafeTarEntryPath('qwen-code/node/lib/foo..bar')).toBe(true); + expect(isSafeTarEntryPath('qwen-code/.../file.txt')).toBe(true); + }); + + it('rejects parent-directory segments and absolute paths', () => { + expect(isSafeTarEntryPath('../qwen-code/manifest.json')).toBe(false); + expect(isSafeTarEntryPath('qwen-code/../manifest.json')).toBe(false); + expect(isSafeTarEntryPath('qwen-code\\..\\manifest.json')).toBe(false); + expect(isSafeTarEntryPath('/tmp/qwen-code/manifest.json')).toBe(false); + expect(isSafeTarEntryPath('C:\\tmp\\qwen-code\\manifest.json')).toBe( + false, + ); + expect(isSafeTarEntryPath('')).toBe(false); + }); + }); + describe('rollbackStandaloneUpdate — concurrent lock protection', () => { it('returns error when an active update holds the lock', () => { const standaloneDir = path.join(tempDir, 'qwen-code'); diff --git a/packages/cli/src/utils/standalone-update.ts b/packages/cli/src/utils/standalone-update.ts index 32dad6ca844..8df0418b1c3 100644 --- a/packages/cli/src/utils/standalone-update.ts +++ b/packages/cli/src/utils/standalone-update.ts @@ -216,6 +216,14 @@ function validateExtractedPaths(resolvedDest: string): void { } } +export function isSafeTarEntryPath(entryPath: string): boolean { + if (entryPath.length === 0) return false; + if (path.posix.isAbsolute(entryPath) || path.win32.isAbsolute(entryPath)) { + return false; + } + return !entryPath.split(/[\\/]+/).includes('..'); +} + async function extractArchive( archivePath: string, destDir: string, @@ -250,7 +258,7 @@ async function extractArchive( cwd: destDir, preservePaths: false, filter: (p, entry) => { - if (p.startsWith('/') || p.includes('..')) return false; + if (!isSafeTarEntryPath(p)) return false; if ( 'type' in entry && entry.type === 'SymbolicLink' &&