Skip to content

Commit 9c9a422

Browse files
authored
fix(security): add guard against Zip Slip vulnerability (CWE-22) (#18)
Signed-off-by: dankeboy36 <[email protected]>
1 parent e9d9faf commit 9c9a422

File tree

10 files changed

+122
-9
lines changed

10 files changed

+122
-9
lines changed

.github/workflows/build.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
name: Build
2+
permissions:
3+
contents: read
4+
pull-requests: write
5+
26
on:
37
push:
48
branches:

.github/workflows/pr-title.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
name: PR Title
2+
permissions:
3+
contents: read
4+
pull-requests: write
25

36
on:
47
pull_request_target:
@@ -12,6 +15,6 @@ jobs:
1215
validate:
1316
runs-on: ubuntu-latest
1417
steps:
15-
- uses: amannn/[email protected]
18+
- uses: amannn/action-semantic-pull-request@d2ab30dcffc66150340abb5b947d518a3c3ce9cb # v3.1.0
1619
env:
1720
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

fake-tools/zip-slip/evil.tar.bz2

227 Bytes
Binary file not shown.

fake-tools/zip-slip/evil.tar.gz

225 Bytes
Binary file not shown.

fake-tools/zip-slip/evil.zip

192 Bytes
Binary file not shown.

fake-tools/zip-slip/notice.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
https://stackoverflow.com/a/74126959

src/extract.js

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ const bz2 = require('unbzip2-stream')
1111
const unzip = require('unzip-stream')
1212

1313
const { createLog } = require('./log')
14-
const { ProgressStream } = require('./progress')
1514

1615
/**
1716
* @typedef {Object} ExtractParams
@@ -72,11 +71,20 @@ async function extract({ source, archiveType, counter }) {
7271
async function extractZip({ source, destinationPath, counter }) {
7372
const log = createLog('extractZip')
7473

74+
const invalidEntries = []
7575
const transformEntry = new Transform({
7676
objectMode: true,
7777
transform: async (entry, _, next) => {
7878
counter?.onEnter(entry.size)
7979
const entryPath = entry.path
80+
// unzip-stream guards against `..` entry paths by converting them to `.`
81+
// https://github.com/mhr3/unzip-stream/commit/d5823009634ad448873ec984bed84c18ee92f9b5#diff-fda971882fda4a106029f88d4b0a6eebeb04e7847cae8516b332b5b57e7e3370R153-R154
82+
if (entryPath.split(path.sep).includes('.')) {
83+
log('invalid archive entry', entryPath)
84+
invalidEntries.push(entryPath)
85+
next()
86+
return
87+
}
8088
const destinationFilePath = path.join(destinationPath, entryPath)
8189
log('extracting', destinationFilePath)
8290
await pipeline(
@@ -94,7 +102,9 @@ async function extractZip({ source, destinationPath, counter }) {
94102
})
95103

96104
await pipeline(source, unzip.Parse(), transformEntry)
97-
105+
if (invalidEntries.length) {
106+
throw new Error('Invalid archive entry')
107+
}
98108
log('extracting to ', destinationPath)
99109
}
100110

@@ -131,6 +141,7 @@ async function extractTar({
131141
}) {
132142
log('extracting to ', destinationPath)
133143

144+
const invalidEntries = []
134145
const extract = tar.extract()
135146

136147
extract.on('entry', (header, stream, next) => {
@@ -139,14 +150,25 @@ async function extractTar({
139150
stream.on('end', next)
140151
return
141152
}
153+
142154
counter?.onEnter(header.size)
143-
let basename = header.name
155+
let entryPath = header.name
144156
if (strip > 0) {
145157
// the path is always POSIX inside the tar. For example, "folder/fake-tool"
146-
const parts = basename.split(path.posix.sep).slice(strip)
147-
basename = parts.length ? parts.join(path.sep) : basename
158+
const parts = entryPath.split(path.posix.sep).slice(strip)
159+
entryPath = parts.length ? parts.join(path.sep) : entryPath
148160
}
149-
const destinationFilePath = path.join(destinationPath, basename)
161+
162+
const destinationFilePath = path.join(destinationPath, entryPath)
163+
const resolvedPath = path.resolve(destinationFilePath)
164+
if (!resolvedPath.startsWith(path.resolve(destinationPath))) {
165+
log('invalid archive entry', entryPath)
166+
invalidEntries.push(entryPath)
167+
stream.resume()
168+
stream.on('end', next)
169+
return
170+
}
171+
150172
fs.mkdir(path.dirname(destinationFilePath), { recursive: true })
151173
.then(() => {
152174
log('extracting', destinationFilePath)
@@ -166,6 +188,9 @@ async function extractTar({
166188
})
167189

168190
await pipeline(source, decompress, extract)
191+
if (invalidEntries.length) {
192+
throw new Error('Invalid archive entry')
193+
}
169194
log('extracted to', destinationPath)
170195
}
171196

src/get.fake-tools.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const {
1616
jest.mock('./download')
1717
jest.mock('./tools')
1818

19+
const itIsNotWin32 = process.platform !== 'win32' ? it : it.skip
20+
1921
describe('get', () => {
2022
let tempDirPath
2123
let cleanup
@@ -96,6 +98,56 @@ describe('get', () => {
9698
expect(fs.access(toolPath, fs.constants.X_OK)).resolves.toBeUndefined()
9799
})
98100

101+
describe('zip-slip', () => {
102+
itIsNotWin32('should error (zip)', async () => {
103+
jest
104+
.mocked(download)
105+
.mockResolvedValue(loadFakeToolByName('zip-slip/evil.zip'))
106+
jest.mocked(createToolBasename).mockReturnValue('evil.sh')
107+
jest.mocked(getArchiveType).mockReturnValue('zip')
108+
109+
await expect(
110+
getTool({
111+
tool: '',
112+
version: '',
113+
destinationFolderPath: tempDirPath,
114+
})
115+
).rejects.toThrow(/invalid archive entry/gi)
116+
})
117+
118+
itIsNotWin32('should error (tar.gz)', async () => {
119+
jest
120+
.mocked(download)
121+
.mockResolvedValue(loadFakeToolByName('zip-slip/evil.tar.gz'))
122+
jest.mocked(createToolBasename).mockReturnValue('evil.sh')
123+
jest.mocked(getArchiveType).mockReturnValue('gzip')
124+
125+
await expect(
126+
getTool({
127+
tool: '',
128+
version: '',
129+
destinationFolderPath: tempDirPath,
130+
})
131+
).rejects.toThrow(/invalid archive entry/gi)
132+
})
133+
134+
itIsNotWin32('should error (tar.bz2)', async () => {
135+
jest
136+
.mocked(download)
137+
.mockResolvedValue(loadFakeToolByName('zip-slip/evil.tar.bz2'))
138+
jest.mocked(createToolBasename).mockReturnValue('evil.sh')
139+
jest.mocked(getArchiveType).mockReturnValue('bzip2')
140+
141+
await expect(
142+
getTool({
143+
tool: '',
144+
version: '',
145+
destinationFolderPath: tempDirPath,
146+
})
147+
).rejects.toThrow(/invalid archive entry/gi)
148+
})
149+
})
150+
99151
describe('with fake server', () => {
100152
let server
101153

src/progress.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
const { EventEmitter } = require('node:events')
2-
const { Transform } = require('node:stream')
32

43
const { createLog } = require('./log')
54

@@ -56,7 +55,8 @@ class ProgressCounter extends EventEmitter {
5655
let extractedPercentage = 0
5756
if (this.toExtractBytes) {
5857
extractedPercentage = Math.trunc(
59-
(this.extractedBytes / this.toExtractBytes) * 50
58+
(this.extractedBytes / this.toExtractBytes) *
59+
(this.toDownloadBytes ? 50 : 100)
6060
)
6161
}
6262

src/progress.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const { ProgressCounter } = require('./progress')
2+
3+
describe('progress', () => {
4+
describe('ProgressCounter', () => {
5+
it('handles when toDownloadBytes is 0', () => {
6+
const counter = new ProgressCounter(0)
7+
const onProgress = jest.fn()
8+
counter.on('progress', onProgress)
9+
10+
counter.onDownload(10)
11+
counter.onDownload(10)
12+
13+
expect(onProgress).not.toHaveBeenCalled()
14+
15+
counter.onEnter(100)
16+
17+
counter.onExtract(25)
18+
counter.onExtract(25)
19+
counter.onExtract(25)
20+
counter.onExtract(25)
21+
22+
expect(onProgress).toHaveBeenNthCalledWith(1, { current: 25 })
23+
expect(onProgress).toHaveBeenNthCalledWith(2, { current: 50 })
24+
expect(onProgress).toHaveBeenNthCalledWith(3, { current: 75 })
25+
expect(onProgress).toHaveBeenNthCalledWith(4, { current: 100 })
26+
})
27+
})
28+
})

0 commit comments

Comments
 (0)