From 63d10cb2d0d20d6512fe623cf7d13c3ddc669e61 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 7 Jun 2026 16:38:08 -0400 Subject: [PATCH] fix(cli): harden ccs bar install + align launch path Resolve the app path via os.homedir() in launch to match install/uninstall; validate the host on every redirect hop (manual follow, not blind maxRedirections); reject zip-slip entries before extracting into ~/Applications. --- src/commands/bar/install-subcommand.ts | 125 +++++++++++++++++++----- src/commands/bar/launch-subcommand.ts | 10 +- tests/unit/commands/bar-command.test.ts | 118 ++++++++++++++++++++++ 3 files changed, 224 insertions(+), 29 deletions(-) diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index 4cd881fb..8f286932 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -145,13 +145,17 @@ async function defaultFetchReleaseAsset(tag: string, asset: string): Promise { const { request } = await import('undici'); @@ -162,34 +166,107 @@ async function defaultDownloadAndExtract(url: string, dest: string): Promise= 300 && statusCode < 400) { + const location = Array.isArray(headers['location']) + ? headers['location'][0] + : headers['location']; - // Extract the zip into dest - await execFileAsync('unzip', ['-o', tmpZip, '-d', dest]); + if (!location) { + throw new Error(`Redirect (HTTP ${statusCode}) from ${currentUrl} has no Location header`); + } - // Clean up the temp archive - try { - fs.unlinkSync(tmpZip); - } catch { - /* ignore */ + // Resolve relative redirects against the current URL + const resolved = new URL(location, currentUrl).toString(); + + // Re-validate the redirect target — this is the key fix for #6 + validateDownloadUrl(resolved); + + if (redirectsFollowed >= MAX_REDIRECTS) { + throw new Error(`Too many redirects (>${MAX_REDIRECTS}) while downloading ${url}`); + } + + // Drain the body to free the socket before following the redirect + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (body as any).dump?.(); + currentUrl = resolved; + redirectsFollowed++; + continue; + } + + if (statusCode !== 200) { + throw new Error(`Download failed: HTTP ${statusCode} for ${currentUrl}`); + } + + const tmpZip = path.join(os.tmpdir(), `ccs-bar-${Date.now()}.zip`); + + // Stream body to tmpZip + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await streamPipeline(body as any, createWriteStream(tmpZip)); + + // Fix #14: zip-slip guard — inspect entries before extraction. + // `unzip -l` lists entries in a machine-readable format; we scan for ".." or + // absolute paths that would escape the destination directory. + try { + const { stdout: listing } = await execFileAsync('unzip', ['-l', tmpZip]); + const lines = listing.split('\n'); + for (const line of lines) { + // Entry lines look like: "