Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const fullPath = path.resolve(directory, entry.path)

will path.resolve() be an better option, assume entry.path should be a relative path?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense to me! I think download-artifact ensures that the path is absolute, but it doesn't look like toolkit does any validation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, doing that throws off the check below for the directory path 😞

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind if I changed it back? On second thought, it might be better to keep this generic and not assume path for anyone utilizing this package. For download-artifact, we have the absolute path: https://github.com/actions/download-artifact/blob/348754975ef0295bfa2c111cba996120cfdf8a5d/src/download-artifact.ts#L38

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i thought directory is absolute path, looks like it can be relative...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directory is absolute when it comes from download-artifact, but isn't guaranteed absolute from other sources.

if (!directory.endsWith(path.sep)) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is important for the comparison below. Otherwise it will fail with, for example, "/tmp/dest/file".startsWith("../dest/")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We decided not to do the path.resolve above since this library could take relative paths!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was definitely failing when we switched to resolve temporarily.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, OK, fullPath.startsWith(directory) should work correctly then.

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