From a3440b05a847e087015657953a899ea5a5ab05eb Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:06:41 +0000 Subject: [PATCH] [Security] Use streaming for GitHub release downloads Hardened 'downloadGitHubRelease' in 'packages/cli-kit/src/public/node/github.ts' to use streaming (via 'pipeline' and 'createFileWriteStream') instead of 'response.arrayBuffer()'. This prevents potential Out-of-Memory (OOM) issues or denial-of-service vulnerabilities when handling large release assets by ensuring they are not loaded entirely into memory. --- .../cli-kit/src/public/node/github.test.ts | 25 ++++++++++++++++--- packages/cli-kit/src/public/node/github.ts | 9 ++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/cli-kit/src/public/node/github.test.ts b/packages/cli-kit/src/public/node/github.test.ts index d02f427c6e4..fc8b8622473 100644 --- a/packages/cli-kit/src/public/node/github.test.ts +++ b/packages/cli-kit/src/public/node/github.test.ts @@ -13,6 +13,7 @@ import {readFile} from './fs.js' import {isExecutable} from 'is-executable' import {describe, expect, test, vi} from 'vitest' import {Response} from 'node-fetch' +import {Readable} from 'stream' vi.mock('./http.js') @@ -180,10 +181,10 @@ describe('downloadGitHubRelease', () => { testWithTempDir('successfully downloads the release asset', async ({tempDir}) => { // GIVEN const downloadContent = 'hello' - const content = Buffer.from(downloadContent) + const content = Readable.from(downloadContent) const mockResponse = { ok: true, - arrayBuffer: vi.fn().mockResolvedValue(content), + body: content, } vi.mocked(fetch).mockResolvedValue(mockResponse as any) @@ -221,7 +222,25 @@ describe('downloadGitHubRelease', () => { testWithTempDir('throws an AbortError when the response is not ok', async ({tempDir}) => { // GIVEN - vi.mocked(downloadFile).mockRejectedValue(new Error('Not Found')) + vi.mocked(fetch).mockResolvedValue({ + ok: false, + statusText: 'Not Found', + } as any) + const targetPath = joinPath(tempDir, 'downloads', 'example') + + // WHEN + const result = downloadGitHubRelease(repo, version, asset, targetPath) + + // THEN + await expect(result).rejects.toThrow(AbortError) + }) + + testWithTempDir('throws an AbortError when the response body is missing', async ({tempDir}) => { + // GIVEN + vi.mocked(fetch).mockResolvedValue({ + ok: true, + body: null, + } as any) const targetPath = joinPath(tempDir, 'downloads', 'example') // WHEN diff --git a/packages/cli-kit/src/public/node/github.ts b/packages/cli-kit/src/public/node/github.ts index 0018b744dad..84e9f5e4e02 100644 --- a/packages/cli-kit/src/public/node/github.ts +++ b/packages/cli-kit/src/public/node/github.ts @@ -1,10 +1,11 @@ import {outputContent, outputDebug, outputToken} from './output.js' import {err, ok, Result} from './result.js' import {fetch, Response} from './http.js' -import {writeFile, mkdir, inTemporaryDirectory, moveFile, chmod} from './fs.js' +import {mkdir, inTemporaryDirectory, moveFile, chmod, createFileWriteStream} from './fs.js' import {dirname, joinPath} from './path.js' import {runWithTimer} from './metadata.js' import {AbortError} from './error.js' +import {pipeline} from 'stream/promises' class GitHubClientError extends Error { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -162,8 +163,10 @@ export async function downloadGitHubRelease( ) } - const buffer = await response.arrayBuffer() - await writeFile(tempPath, Buffer.from(buffer)) + if (!response.body) { + throw new AbortError(`Failed to download ${assetName}: No response body`) + } + await pipeline(response.body, createFileWriteStream(tempPath)) await chmod(tempPath, 0o755) await mkdir(dirname(targetPath))