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: "