Skip to content

Commit

Permalink
Merge pull request #1666 from actions/bethanyj28/download-path
Browse files Browse the repository at this point in the history
Use `unzip.Parse` over `unzip.Extract`
  • Loading branch information
bethanyj28 committed Feb 23, 2024
2 parents 415c42d + 6cf4fbc commit 88f7a7b
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 9 deletions.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion packages/artifact/RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,8 @@

### 2.1.1

- Updated `isGhes` check to include `.ghe.com` and `.ghe.localhost` as accepted hosts
- Updated `isGhes` check to include `.ghe.com` and `.ghe.localhost` as accepted hosts

### 2.1.2

- Updated the stream extract functionality to use `unzip.Parse()` instead of `unzip.Extract()` for greater control of unzipping artifacts
55 changes: 55 additions & 0 deletions packages/artifact/__tests__/download-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ const mockGetArtifactFailure = jest.fn(() => {
}
})

const mockGetArtifactMalicious = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.push(fs.readFileSync(path.join(__dirname, 'fixtures', 'evil.zip'))) // evil.zip contains files that are formatted x/../../etc/hosts
message.push(null)
return {
message
}
})

describe('download-artifact', () => {
describe('public', () => {
beforeEach(setup)
Expand Down Expand Up @@ -170,6 +180,51 @@ describe('download-artifact', () => {
expect(response.downloadPath).toBe(fixtures.workspaceDir)
})

it('should not allow path traversal from malicious artifacts', async () => {
const downloadArtifactMock = github.getOctokit(fixtures.token).rest
.actions.downloadArtifact as MockedDownloadArtifact
downloadArtifactMock.mockResolvedValueOnce({
headers: {
location: fixtures.blobStorageUrl
},
status: 302,
url: '',
data: Buffer.from('')
})

const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetArtifactMalicious
}
}
)

await expect(
downloadArtifactPublic(
fixtures.artifactID,
fixtures.repositoryOwner,
fixtures.repositoryName,
fixtures.token
)
).rejects.toBeInstanceOf(Error)

expect(downloadArtifactMock).toHaveBeenCalledWith({
owner: fixtures.repositoryOwner,
repo: fixtures.repositoryName,
artifact_id: fixtures.artifactID,
archive_format: 'zip',
request: {
redirect: 'manual'
}
})

expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
expect(mockGetArtifactMalicious).toHaveBeenCalledWith(
fixtures.blobStorageUrl
)
})

it('should successfully download an artifact to user defined path', async () => {
const customPath = path.join(testDir, 'custom')

Expand Down
Binary file added packages/artifact/__tests__/fixtures/evil.zip
Binary file not shown.
4 changes: 2 additions & 2 deletions packages/artifact/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/artifact/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@actions/artifact",
"version": "2.1.1",
"version": "2.1.2",
"preview": true,
"description": "Actions artifact lib",
"keywords": [
Expand Down
57 changes: 55 additions & 2 deletions packages/artifact/src/internal/download/download-artifact.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import fs from 'fs/promises'
import * as stream from 'stream'
import {createWriteStream} from 'fs'
import * as path from 'path'
import * as github from '@actions/github'
import * as core from '@actions/core'
import * as httpClient from '@actions/http-client'
Expand Down Expand Up @@ -44,6 +47,11 @@ async function streamExtract(url: string, directory: string): Promise<void> {
await streamExtractExternal(url, directory)
return
} catch (error) {
if (error.message.includes('Malformed extraction path')) {
throw new Error(
`Artifact download failed with unretryable error: ${error.message}`
)
}
retryCount++
core.debug(
`Failed to download artifact after ${retryCount} retries due to ${error.message}. Retrying in 5 seconds...`
Expand Down Expand Up @@ -78,6 +86,8 @@ export async function streamExtractExternal(
}
const timer = setTimeout(timerFn, timeout)

const createdDirectories = new Set<string>()
createdDirectories.add(directory)
response.message
.on('data', () => {
timer.refresh()
Expand All @@ -89,8 +99,51 @@ export async function streamExtractExternal(
clearTimeout(timer)
reject(error)
})
.pipe(unzip.Extract({path: directory}))
.on('close', () => {
.pipe(unzip.Parse())
.pipe(
new stream.Transform({
objectMode: true,
transform: async (entry, _, callback) => {
const fullPath = path.normalize(path.join(directory, entry.path))
if (!directory.endsWith(path.sep)) {
directory += path.sep
}
if (!fullPath.startsWith(directory)) {
reject(new Error(`Malformed extraction path: ${fullPath}`))
}

core.debug(`Extracting artifact entry: ${fullPath}`)
if (entry.type === 'Directory') {
if (!createdDirectories.has(fullPath)) {
createdDirectories.add(fullPath)
await resolveOrCreateDirectory(fullPath).then(() => {
entry.autodrain()
callback()
})
} else {
entry.autodrain()
callback()
}
} else {
if (!createdDirectories.has(path.dirname(fullPath))) {
createdDirectories.add(path.dirname(fullPath))
await resolveOrCreateDirectory(path.dirname(fullPath)).then(
() => {
entry.autodrain()
callback()
}
)
}

const writeStream = createWriteStream(fullPath)
writeStream.on('finish', callback)
writeStream.on('error', reject)
entry.pipe(writeStream)
}
}
})
)
.on('finish', async () => {
clearTimeout(timer)
resolve()
})
Expand Down

0 comments on commit 88f7a7b

Please sign in to comment.