From 2a356168e60847c0fbbcfe0436feed6452478793 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 13:48:31 -0500 Subject: [PATCH 01/15] Download artifact: don't extract the downloaded file if the content-type isn't a zip --- .vscode/launch.json | 36 ++++ .../__tests__/download-artifact.test.ts | 169 +++++++++++++++++- .../internal/download/download-artifact.ts | 83 ++++++--- 3 files changed, 261 insertions(+), 27 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..94965ca7b2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug Jest Tests", + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", + "args": [ + "--runInBand", + "--testTimeout", + "10000" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true + }, + { + "type": "node", + "request": "launch", + "name": "Debug Current Test File", + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", + "args": [ + "--runInBand", + "--testTimeout", + "10000", + "${relativeFile}" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true + } + ] +} \ No newline at end of file diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index 02390d6f00..465beccd1a 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -104,6 +104,7 @@ const cleanup = async (): Promise => { const mockGetArtifactSuccess = jest.fn(() => { const message = new http.IncomingMessage(new net.Socket()) message.statusCode = 200 + message.headers['content-type'] = 'application/zip' message.push(fs.readFileSync(fixtures.exampleArtifact.path)) message.push(null) return { @@ -114,6 +115,7 @@ const mockGetArtifactSuccess = jest.fn(() => { const mockGetArtifactHung = jest.fn(() => { const message = new http.IncomingMessage(new net.Socket()) message.statusCode = 200 + message.headers['content-type'] = 'application/zip' // Don't push any data or call push(null) to end the stream // This creates a stream that hangs and never completes return { @@ -134,6 +136,7 @@ const mockGetArtifactFailure = jest.fn(() => { const mockGetArtifactMalicious = jest.fn(() => { const message = new http.IncomingMessage(new net.Socket()) message.statusCode = 200 + message.headers['content-type'] = 'application/zip' message.push(fs.readFileSync(path.join(__dirname, 'fixtures', 'evil.zip'))) // evil.zip contains files that are formatted x/../../etc/hosts message.push(null) return { @@ -623,6 +626,13 @@ describe('download-artifact', () => { }) describe('streamExtractExternal', () => { + beforeEach(async () => { + await setup() + // Create workspace directory for streamExtractExternal tests + await fs.promises.mkdir(fixtures.workspaceDir, {recursive: true}) + }) + afterEach(cleanup) + it('should fail if the timeout is exceeded', async () => { const mockSlowGetArtifact = jest.fn(mockGetArtifactHung) @@ -641,12 +651,169 @@ describe('download-artifact', () => { {timeout: 2} ) expect(true).toBe(false) // should not be called - } catch (e) { + } catch (e : any) { expect(e).toBeInstanceOf(Error) expect(e.message).toContain('did not respond in 2ms') expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) expect(mockSlowGetArtifact).toHaveBeenCalledTimes(1) } }) + + it('should extract zip file when content-type is application/zip', async () => { + const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( + () => { + return { + get: mockGetArtifactSuccess + } + } + ) + + await streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir + ) + + expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) + // Verify files were extracted (not saved as a single file) + await expectExtractedArchive(fixtures.workspaceDir) + }) + + it('should save raw file without extracting when content-type is not a zip', async () => { + const rawFileContent = 'This is a raw text file, not a zip' + const rawFileName = 'my-artifact.txt' + + const mockGetRawFile = jest.fn(() => { + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'text/plain' + message.headers['content-disposition'] = `attachment; filename="${rawFileName}"` + message.push(Buffer.from(rawFileContent)) + message.push(null) + return { + message + } + }) + + const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( + () => { + return { + get: mockGetRawFile + } + } + ) + + await streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir + ) + + expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) + // Verify file was saved as-is, not extracted + const savedFilePath = path.join(fixtures.workspaceDir, rawFileName) + expect(fs.existsSync(savedFilePath)).toBe(true) + expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent) + }) + + it('should save raw file with default name when content-disposition is missing', async () => { + const rawFileContent = 'Binary content here' + + const mockGetRawFileNoDisposition = jest.fn(() => { + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'application/octet-stream' + // No content-disposition header + message.push(Buffer.from(rawFileContent)) + message.push(null) + return { + message + } + }) + + const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( + () => { + return { + get: mockGetRawFileNoDisposition + } + } + ) + + await streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir + ) + + expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) + // Verify file was saved with default name 'artifact' + const savedFilePath = path.join(fixtures.workspaceDir, 'artifact') + expect(fs.existsSync(savedFilePath)).toBe(true) + expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent) + }) + + it('should not attempt to unzip when content-type is image/png', async () => { + const pngFileName = 'screenshot.png' + // Simple PNG header bytes for testing + const pngContent = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + + const mockGetPngFile = jest.fn(() => { + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'image/png' + message.headers['content-disposition'] = `attachment; filename="${pngFileName}"` + message.push(pngContent) + message.push(null) + return { + message + } + }) + + const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( + () => { + return { + get: mockGetPngFile + } + } + ) + + await streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir + ) + + expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) + // Verify PNG was saved as-is + const savedFilePath = path.join(fixtures.workspaceDir, pngFileName) + expect(fs.existsSync(savedFilePath)).toBe(true) + expect(fs.readFileSync(savedFilePath)).toEqual(pngContent) + }) + + it('should extract when content-type is application/x-zip-compressed', async () => { + const mockGetZipCompressed = jest.fn(() => { + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'application/x-zip-compressed' + message.push(fs.readFileSync(fixtures.exampleArtifact.path)) + message.push(null) + return { + message + } + }) + + const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( + () => { + return { + get: mockGetZipCompressed + } + } + ) + + await streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir + ) + + expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) + // Verify files were extracted + await expectExtractedArchive(fixtures.workspaceDir) + }) }) }) diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index a7404fe6f9..f8324791dc 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -1,6 +1,8 @@ import fs from 'fs/promises' +import * as fsSync from 'fs' import * as crypto from 'crypto' import * as stream from 'stream' +import * as path from 'path' import * as github from '@actions/github' import * as core from '@actions/core' @@ -21,6 +23,7 @@ import { } from '../../generated/index.js' import {getBackendIdsFromToken} from '../shared/util.js' import {ArtifactNotFoundError} from '../shared/errors.js' +import { on } from 'events' const scrubQueryParameters = (url: string): string => { const parsed = new URL(url) @@ -75,6 +78,26 @@ export async function streamExtractExternal( ) } + const contentType = response.message.headers['content-type'] || '' + const isZip = + contentType === 'application/zip' || + contentType === 'application/x-zip-compressed' || + contentType === 'zip' + + // Extract filename from Content-Disposition header + const contentDisposition = + response.message.headers['content-disposition'] || '' + let fileName = 'artifact' + const filenameMatch = contentDisposition.match( + /filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?/i + ) + if (filenameMatch && filenameMatch[1]) { + fileName = decodeURIComponent(filenameMatch[1].trim()) + } + + core.debug(`Content-Type: ${contentType}, isZip: ${isZip}`) + core.debug(`Content-Disposition: ${contentDisposition}, fileName: ${fileName}`) + let sha256Digest: string | undefined = undefined return new Promise((resolve, reject) => { @@ -87,37 +110,45 @@ export async function streamExtractExternal( } const timer = setTimeout(timerFn, opts.timeout) + const onError = (error: Error): void => { + core.debug( + `response.message: Artifact download failed: ${error.message}` + ) + clearTimeout(timer) + reject(error) + } + const hashStream = crypto.createHash('sha256').setEncoding('hex') - const passThrough = new stream.PassThrough() + const passThrough = new stream.PassThrough() + .on('data', () => { + timer.refresh() + }) + .on('error', onError) response.message.pipe(passThrough) passThrough.pipe(hashStream) - const extractStream = passThrough - extractStream - .on('data', () => { - timer.refresh() - }) - .on('error', (error: Error) => { - core.debug( - `response.message: Artifact download failed: ${error.message}` - ) - clearTimeout(timer) - reject(error) - }) - .pipe(unzip.Extract({path: directory})) - .on('close', () => { - clearTimeout(timer) - if (hashStream) { - hashStream.end() - sha256Digest = hashStream.read() as string - core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`) - } - resolve({sha256Digest: `sha256:${sha256Digest}`}) - }) - .on('error', (error: Error) => { - reject(error) - }) + const onClose = (): void => { + clearTimeout(timer) + if (hashStream) { + hashStream.end() + sha256Digest = hashStream.read() as string + core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`) + } + resolve({sha256Digest: `sha256:${sha256Digest}`}) + } + + if (isZip) { + // Extract zip file + passThrough.pipe(unzip.Extract({path: directory})).on('close', onClose).on('error', onError) + } else { + // Save raw file without extracting + const filePath = path.join(directory, fileName) + const writeStream = fsSync.createWriteStream(filePath) + + core.info(`Downloading raw file (non-zip) to: ${filePath}`) + passThrough.pipe(writeStream).on('close', onClose).on('error', onError) + } }) } From c2139ac002f1d32edda7cc793c057d4108e14611 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 13:59:24 -0500 Subject: [PATCH 02/15] Remove unused `import` --- packages/artifact/src/internal/download/download-artifact.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index f8324791dc..fdd2219077 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -23,7 +23,6 @@ import { } from '../../generated/index.js' import {getBackendIdsFromToken} from '../shared/util.js' import {ArtifactNotFoundError} from '../shared/errors.js' -import { on } from 'events' const scrubQueryParameters = (url: string): string => { const parsed = new URL(url) From e7359c003106d4ee27e6a943dbf301236221a38e Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 14:35:09 -0500 Subject: [PATCH 03/15] Add support for specifying whether to skip decompressing --- .../__tests__/download-artifact.test.ts | 26 +++++++++++++++++ .../internal/download/download-artifact.ts | 28 +++++++++++++------ .../src/internal/shared/interfaces.ts | 6 ++++ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index 465beccd1a..82c6d740a2 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -815,5 +815,31 @@ describe('download-artifact', () => { // Verify files were extracted await expectExtractedArchive(fixtures.workspaceDir) }) + + it('should skip decompression when skipDecompress option is true even for zip content-type', async () => { + const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( + () => { + return { + get: mockGetArtifactSuccess + } + } + ) + + await streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir, + {skipDecompress: true} + ) + + expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) + // Verify zip was saved as-is, not extracted + // When skipDecompress is true, the file should be saved with default name 'artifact' + const savedFilePath = path.join(fixtures.workspaceDir, 'artifact') + expect(fs.existsSync(savedFilePath)).toBe(true) + // The saved file should be the raw zip content + const savedContent = fs.readFileSync(savedFilePath) + const originalZipContent = fs.readFileSync(fixtures.exampleArtifact.path) + expect(savedContent).toEqual(originalZipContent) + }) }) }) diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index fdd2219077..173c0b57d9 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -45,12 +45,13 @@ async function exists(path: string): Promise { async function streamExtract( url: string, - directory: string + directory: string, + skipDecompress?: boolean ): Promise { let retryCount = 0 while (retryCount < 5) { try { - return await streamExtractExternal(url, directory) + return await streamExtractExternal(url, directory, {skipDecompress}) } catch (error) { retryCount++ core.debug( @@ -67,8 +68,9 @@ async function streamExtract( export async function streamExtractExternal( url: string, directory: string, - opts: {timeout: number} = {timeout: 30 * 1000} + opts: {timeout?: number; skipDecompress?: boolean} = {} ): Promise { + const {timeout = 30 * 1000, skipDecompress = false} = opts const client = new httpClient.HttpClient(getUserAgentString()) const response = await client.get(url) if (response.message.statusCode !== 200) { @@ -94,7 +96,7 @@ export async function streamExtractExternal( fileName = decodeURIComponent(filenameMatch[1].trim()) } - core.debug(`Content-Type: ${contentType}, isZip: ${isZip}`) + core.debug(`Content-Type: ${contentType}, isZip: ${isZip}, skipDecompress: ${skipDecompress}`) core.debug(`Content-Disposition: ${contentDisposition}, fileName: ${fileName}`) let sha256Digest: string | undefined = undefined @@ -102,12 +104,12 @@ export async function streamExtractExternal( return new Promise((resolve, reject) => { const timerFn = (): void => { const timeoutError = new Error( - `Blob storage chunk did not respond in ${opts.timeout}ms` + `Blob storage chunk did not respond in ${timeout}ms` ) response.message.destroy(timeoutError) reject(timeoutError) } - const timer = setTimeout(timerFn, opts.timeout) + const timer = setTimeout(timerFn, timeout) const onError = (error: Error): void => { core.debug( @@ -137,7 +139,7 @@ export async function streamExtractExternal( resolve({sha256Digest: `sha256:${sha256Digest}`}) } - if (isZip) { + if (isZip && !skipDecompress) { // Extract zip file passThrough.pipe(unzip.Extract({path: directory})).on('close', onClose).on('error', onError) } else { @@ -193,7 +195,11 @@ export async function downloadArtifactPublic( try { core.info(`Starting download of artifact to: ${downloadPath}`) - const extractResponse = await streamExtract(location, downloadPath) + const extractResponse = await streamExtract( + location, + downloadPath, + options?.skipDecompress + ) core.info(`Artifact download completed successfully.`) if (options?.expectedHash) { if (options?.expectedHash !== extractResponse.sha256Digest) { @@ -254,7 +260,11 @@ export async function downloadArtifactInternal( try { core.info(`Starting download of artifact to: ${downloadPath}`) - const extractResponse = await streamExtract(signedUrl, downloadPath) + const extractResponse = await streamExtract( + signedUrl, + downloadPath, + options?.skipDecompress + ) core.info(`Artifact download completed successfully.`) if (options?.expectedHash) { if (options?.expectedHash !== extractResponse.sha256Digest) { diff --git a/packages/artifact/src/internal/shared/interfaces.ts b/packages/artifact/src/internal/shared/interfaces.ts index 9675d39a3e..784ff98d1b 100644 --- a/packages/artifact/src/internal/shared/interfaces.ts +++ b/packages/artifact/src/internal/shared/interfaces.ts @@ -113,6 +113,12 @@ export interface DownloadArtifactOptions { * matches the expected hash. */ expectedHash?: string + + /** + * If true, the downloaded artifact will not be automatically extracted/decompressed. + * The artifact will be saved as-is to the destination path. + */ + skipDecompress?: boolean } export interface StreamExtractResponse { From 3d05a1c31bb9ac817ed782aed3cff6c39549a595 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 14:48:39 -0500 Subject: [PATCH 04/15] Prevent path traversal attacks --- .../__tests__/download-artifact.test.ts | 79 +++++++++++++++++++ .../internal/download/download-artifact.ts | 4 +- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index 82c6d740a2..db7e0df389 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -841,5 +841,84 @@ describe('download-artifact', () => { const originalZipContent = fs.readFileSync(fixtures.exampleArtifact.path) expect(savedContent).toEqual(originalZipContent) }) + + it('should sanitize path traversal attempts in Content-Disposition filename', async () => { + const rawFileContent = 'malicious content' + const maliciousFileName = '../../../etc/passwd' + + const mockGetMaliciousFile = jest.fn(() => { + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'text/plain' + message.headers['content-disposition'] = `attachment; filename="${maliciousFileName}"` + message.push(Buffer.from(rawFileContent)) + message.push(null) + return { + message + } + }) + + const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( + () => { + return { + get: mockGetMaliciousFile + } + } + ) + + await streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir + ) + + expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) + // Verify file was saved with sanitized name (just 'passwd', not the full path) + const sanitizedFileName = 'passwd' + const savedFilePath = path.join(fixtures.workspaceDir, sanitizedFileName) + expect(fs.existsSync(savedFilePath)).toBe(true) + expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent) + + // Verify the file was NOT written outside the workspace directory + const maliciousPath = path.resolve(fixtures.workspaceDir, maliciousFileName) + expect(fs.existsSync(maliciousPath)).toBe(false) + }) + + it('should handle encoded path traversal attempts in Content-Disposition filename', async () => { + const rawFileContent = 'encoded malicious content' + // URL encoded version of ../../../etc/passwd + const encodedMaliciousFileName = '..%2F..%2F..%2Fetc%2Fpasswd' + + const mockGetEncodedMaliciousFile = jest.fn(() => { + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'application/octet-stream' + message.headers['content-disposition'] = `attachment; filename="${encodedMaliciousFileName}"` + message.push(Buffer.from(rawFileContent)) + message.push(null) + return { + message + } + }) + + const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( + () => { + return { + get: mockGetEncodedMaliciousFile + } + } + ) + + await streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir + ) + + expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) + // After decoding and sanitizing, should just be 'passwd' + const sanitizedFileName = 'passwd' + const savedFilePath = path.join(fixtures.workspaceDir, sanitizedFileName) + expect(fs.existsSync(savedFilePath)).toBe(true) + expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent) + }) }) }) diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index 173c0b57d9..8ebe66dfd1 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -93,7 +93,9 @@ export async function streamExtractExternal( /filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?/i ) if (filenameMatch && filenameMatch[1]) { - fileName = decodeURIComponent(filenameMatch[1].trim()) + // Sanitize fileName to prevent path traversal attacks + // Use path.basename to extract only the filename component + fileName = path.basename(decodeURIComponent(filenameMatch[1].trim())) } core.debug(`Content-Type: ${contentType}, isZip: ${isZip}, skipDecompress: ${skipDecompress}`) From a3377525d3d5daec53b0cff818611f3fd8a891fe Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 14:48:46 -0500 Subject: [PATCH 05/15] Fix indenting --- .../artifact/src/internal/download/download-artifact.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index 8ebe66dfd1..895ba14ec2 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -123,10 +123,10 @@ export async function streamExtractExternal( const hashStream = crypto.createHash('sha256').setEncoding('hex') const passThrough = new stream.PassThrough() - .on('data', () => { - timer.refresh() - }) - .on('error', onError) + .on('data', () => { + timer.refresh() + }) + .on('error', onError) response.message.pipe(passThrough) passThrough.pipe(hashStream) From 9700641a19a0110d77bc78e730d558410e1f91d9 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 14:40:04 -0500 Subject: [PATCH 06/15] Update packages/artifact/__tests__/download-artifact.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/artifact/__tests__/download-artifact.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index db7e0df389..496cfd1475 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -651,7 +651,8 @@ describe('download-artifact', () => { {timeout: 2} ) expect(true).toBe(false) // should not be called - } catch (e : any) { + } catch (error: unknown) { + const e = error as Error expect(e).toBeInstanceOf(Error) expect(e.message).toContain('did not respond in 2ms') expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) From 3d9761876cd06bcaf27325ccd3391bdbf9725b10 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 14:42:27 -0500 Subject: [PATCH 07/15] Parse the mime type out of the content-type header Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../artifact/src/internal/download/download-artifact.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index 895ba14ec2..4a14497448 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -80,10 +80,11 @@ export async function streamExtractExternal( } const contentType = response.message.headers['content-type'] || '' + const mimeType = contentType.split(';', 1)[0].trim().toLowerCase() const isZip = - contentType === 'application/zip' || - contentType === 'application/x-zip-compressed' || - contentType === 'zip' + mimeType === 'application/zip' || + mimeType === 'application/x-zip-compressed' || + mimeType === 'zip' // Extract filename from Content-Disposition header const contentDisposition = From bb4c482f33ad30daa78861daafbd38fcefea9fe2 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 15:07:34 -0500 Subject: [PATCH 08/15] Fix some linting issues --- .../__tests__/download-artifact.test.ts | 21 ++++++++++++----- .../internal/download/download-artifact.ts | 23 +++++++++++-------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index 496cfd1475..3865e20b78 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -687,7 +687,8 @@ describe('download-artifact', () => { const message = new http.IncomingMessage(new net.Socket()) message.statusCode = 200 message.headers['content-type'] = 'text/plain' - message.headers['content-disposition'] = `attachment; filename="${rawFileName}"` + message.headers['content-disposition'] = + `attachment; filename="${rawFileName}"` message.push(Buffer.from(rawFileContent)) message.push(null) return { @@ -753,13 +754,16 @@ describe('download-artifact', () => { it('should not attempt to unzip when content-type is image/png', async () => { const pngFileName = 'screenshot.png' // Simple PNG header bytes for testing - const pngContent = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + const pngContent = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a + ]) const mockGetPngFile = jest.fn(() => { const message = new http.IncomingMessage(new net.Socket()) message.statusCode = 200 message.headers['content-type'] = 'image/png' - message.headers['content-disposition'] = `attachment; filename="${pngFileName}"` + message.headers['content-disposition'] = + `attachment; filename="${pngFileName}"` message.push(pngContent) message.push(null) return { @@ -851,7 +855,8 @@ describe('download-artifact', () => { const message = new http.IncomingMessage(new net.Socket()) message.statusCode = 200 message.headers['content-type'] = 'text/plain' - message.headers['content-disposition'] = `attachment; filename="${maliciousFileName}"` + message.headers['content-disposition'] = + `attachment; filename="${maliciousFileName}"` message.push(Buffer.from(rawFileContent)) message.push(null) return { @@ -880,7 +885,10 @@ describe('download-artifact', () => { expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent) // Verify the file was NOT written outside the workspace directory - const maliciousPath = path.resolve(fixtures.workspaceDir, maliciousFileName) + const maliciousPath = path.resolve( + fixtures.workspaceDir, + maliciousFileName + ) expect(fs.existsSync(maliciousPath)).toBe(false) }) @@ -893,7 +901,8 @@ describe('download-artifact', () => { const message = new http.IncomingMessage(new net.Socket()) message.statusCode = 200 message.headers['content-type'] = 'application/octet-stream' - message.headers['content-disposition'] = `attachment; filename="${encodedMaliciousFileName}"` + message.headers['content-disposition'] = + `attachment; filename="${encodedMaliciousFileName}"` message.push(Buffer.from(rawFileContent)) message.push(null) return { diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index 4a14497448..bed06385ca 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -99,8 +99,12 @@ export async function streamExtractExternal( fileName = path.basename(decodeURIComponent(filenameMatch[1].trim())) } - core.debug(`Content-Type: ${contentType}, isZip: ${isZip}, skipDecompress: ${skipDecompress}`) - core.debug(`Content-Disposition: ${contentDisposition}, fileName: ${fileName}`) + core.debug( + `Content-Type: ${contentType}, isZip: ${isZip}, skipDecompress: ${skipDecompress}` + ) + core.debug( + `Content-Disposition: ${contentDisposition}, fileName: ${fileName}` + ) let sha256Digest: string | undefined = undefined @@ -115,18 +119,16 @@ export async function streamExtractExternal( const timer = setTimeout(timerFn, timeout) const onError = (error: Error): void => { - core.debug( - `response.message: Artifact download failed: ${error.message}` - ) + core.debug(`response.message: Artifact download failed: ${error.message}`) clearTimeout(timer) reject(error) } const hashStream = crypto.createHash('sha256').setEncoding('hex') - const passThrough = new stream.PassThrough() + const passThrough = new stream.PassThrough() .on('data', () => { - timer.refresh() - }) + timer.refresh() + }) .on('error', onError) response.message.pipe(passThrough) @@ -144,7 +146,10 @@ export async function streamExtractExternal( if (isZip && !skipDecompress) { // Extract zip file - passThrough.pipe(unzip.Extract({path: directory})).on('close', onClose).on('error', onError) + passThrough + .pipe(unzip.Extract({path: directory})) + .on('close', onClose) + .on('error', onError) } else { // Save raw file without extracting const filePath = path.join(directory, fileName) From 5357eac1de0a5a0b5933a95e1e2fd147ac85fa87 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 20:23:58 -0500 Subject: [PATCH 09/15] Swap `zip` for `application/zip-compressed` --- packages/artifact/src/internal/download/download-artifact.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index bed06385ca..19fbc71bfe 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -84,7 +84,7 @@ export async function streamExtractExternal( const isZip = mimeType === 'application/zip' || mimeType === 'application/x-zip-compressed' || - mimeType === 'zip' + mimeType === 'application/zip-compressed' // Extract filename from Content-Disposition header const contentDisposition = From 15b1cec994155eaaa3c11e05961b84daff8c854f Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 20:31:02 -0500 Subject: [PATCH 10/15] Test: negative check for malicious paths --- .../artifact/__tests__/download-artifact.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index 3865e20b78..2da378d9f8 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -929,6 +929,19 @@ describe('download-artifact', () => { const savedFilePath = path.join(fixtures.workspaceDir, sanitizedFileName) expect(fs.existsSync(savedFilePath)).toBe(true) expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent) + + // Verify the file was NOT written outside the workspace directory + const maliciousPathEncoded = path.resolve( + fixtures.workspaceDir, + encodedMaliciousFileName + ) + expect(fs.existsSync(maliciousPathEncoded)).toBe(false) + + const maliciousPath = path.resolve( + fixtures.workspaceDir, + "../../../etc/passwd" + ) + expect(fs.existsSync(maliciousPath)).toBe(false) }) }) }) From 0f4cf89e18c7fc90475a75962e47ecacea9c2c0a Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Thu, 29 Jan 2026 15:43:29 -0500 Subject: [PATCH 11/15] Increase the timeout on one of the tests --- packages/artifact/__tests__/download-artifact.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index 2da378d9f8..858cef1271 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -622,7 +622,7 @@ describe('download-artifact', () => { ...fixtures.backendIds, name: fixtures.artifactName }) - }) + }, 38000) }) describe('streamExtractExternal', () => { From 6ced2e75b488f9784c8c2afbc4380d365ed16c50 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Thu, 29 Jan 2026 15:55:42 -0500 Subject: [PATCH 12/15] Check the URL path for `.zip` to see if we can auto-decompress --- .../__tests__/download-artifact.test.ts | 34 +++++++++++++++++++ .../internal/download/download-artifact.ts | 10 ++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index 858cef1271..bd4efab973 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -821,6 +821,40 @@ describe('download-artifact', () => { await expectExtractedArchive(fixtures.workspaceDir) }) + it('should extract zip when URL ends with .zip even if content-type is not application/zip', async () => { + const blobUrlWithZipExtension = + 'https://blob-storage.local/artifact.zip?sig=abc123' + + const mockGetZipByUrl = jest.fn(() => { + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + // Azure Blob Storage may return a generic content-type + message.headers['content-type'] = 'application/octet-stream' + message.push(fs.readFileSync(fixtures.exampleArtifact.path)) + message.push(null) + return { + message + } + }) + + const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( + () => { + return { + get: mockGetZipByUrl + } + } + ) + + await streamExtractExternal( + blobUrlWithZipExtension, + fixtures.workspaceDir + ) + + expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) + // Verify files were extracted based on URL .zip extension + await expectExtractedArchive(fixtures.workspaceDir) + }) + it('should skip decompression when skipDecompress option is true even for zip content-type', async () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index 19fbc71bfe..81c52e9a7b 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -81,10 +81,16 @@ export async function streamExtractExternal( const contentType = response.message.headers['content-type'] || '' const mimeType = contentType.split(';', 1)[0].trim().toLowerCase() + + // Check if the URL path ends with .zip (ignoring query parameters) + const urlPath = new URL(url).pathname.toLowerCase() + const urlEndsWithZip = urlPath.endsWith('.zip') + const isZip = mimeType === 'application/zip' || mimeType === 'application/x-zip-compressed' || - mimeType === 'application/zip-compressed' + mimeType === 'application/zip-compressed' || + urlEndsWithZip // Extract filename from Content-Disposition header const contentDisposition = @@ -100,7 +106,7 @@ export async function streamExtractExternal( } core.debug( - `Content-Type: ${contentType}, isZip: ${isZip}, skipDecompress: ${skipDecompress}` + `Content-Type: ${contentType}, mimeType: ${mimeType}, urlEndsWithZip: ${urlEndsWithZip}, isZip: ${isZip}, skipDecompress: ${skipDecompress}` ) core.debug( `Content-Disposition: ${contentDisposition}, fileName: ${fileName}` From 03354f3178a044deb4e2d1ce1d5bfdca67a9160a Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Thu, 29 Jan 2026 16:01:28 -0500 Subject: [PATCH 13/15] Fix linting issue --- packages/artifact/__tests__/download-artifact.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index bd4efab973..e4bb931928 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -973,7 +973,7 @@ describe('download-artifact', () => { const maliciousPath = path.resolve( fixtures.workspaceDir, - "../../../etc/passwd" + '../../../etc/passwd' ) expect(fs.existsSync(maliciousPath)).toBe(false) }) From 675f6f5832a572ffe3635cd7fb576e655412a372 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Thu, 29 Jan 2026 20:39:35 -0500 Subject: [PATCH 14/15] Bump the package version and add release notes --- packages/artifact/RELEASES.md | 9 +++++++++ packages/artifact/package-lock.json | 5 ++--- packages/artifact/package.json | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/artifact/RELEASES.md b/packages/artifact/RELEASES.md index 7559d0bb21..3290ce624c 100644 --- a/packages/artifact/RELEASES.md +++ b/packages/artifact/RELEASES.md @@ -1,5 +1,14 @@ # @actions/artifact Releases +## 6.1.0 + +- Support downloading non-zip artifacts. Zipped artifacts will be decompressed automatically (with an optional override). Un-zipped artifacts will be downloaded as-is. + +## 6.0.0 + +- **Breaking change**: Package is now ESM-only + - CommonJS consumers must use dynamic `import()` instead of `require()` + ## 5.0.3 - Bump `@actions/http-client` to `3.0.2` diff --git a/packages/artifact/package-lock.json b/packages/artifact/package-lock.json index f4d3ad44b8..ff43ac37c7 100644 --- a/packages/artifact/package-lock.json +++ b/packages/artifact/package-lock.json @@ -1,12 +1,12 @@ { "name": "@actions/artifact", - "version": "6.0.0", + "version": "6.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@actions/artifact", - "version": "6.0.0", + "version": "6.1.0", "license": "MIT", "dependencies": { "@actions/core": "^3.0.0", @@ -1795,7 +1795,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/packages/artifact/package.json b/packages/artifact/package.json index 4418027021..705b9948e2 100644 --- a/packages/artifact/package.json +++ b/packages/artifact/package.json @@ -1,6 +1,6 @@ { "name": "@actions/artifact", - "version": "6.0.0", + "version": "6.1.0", "preview": true, "description": "Actions artifact lib", "keywords": [ From f61f0ce79efe90e262e9a88b77004bad129759be Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Fri, 30 Jan 2026 10:11:00 -0500 Subject: [PATCH 15/15] Remove `launch.json` --- .vscode/launch.json | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 94965ca7b2..0000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Debug Jest Tests", - "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", - "args": [ - "--runInBand", - "--testTimeout", - "10000" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "disableOptimisticBPs": true - }, - { - "type": "node", - "request": "launch", - "name": "Debug Current Test File", - "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", - "args": [ - "--runInBand", - "--testTimeout", - "10000", - "${relativeFile}" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "disableOptimisticBPs": true - } - ] -} \ No newline at end of file