fix: error "zip bomb" when unzipping on linux for some zipped folders #876
Massimo Melina committed
Oct 13, 2025 at 20:31 UTC
3d1fa5fa5290a0b92ab47f3e33e2471c17964ddb
2 files changed
+51
-42
src/QuickZipStream.ts
+47
-38
@@ -34,6 +34,7 @@ interface QuickZipEntry {
34
version: number,
35
extAttr: number,
36
}
37
+// the point of this class is the method applyRange, which allows seeking forward in the zip file quickly (useful to resume downloads)
38
export class QuickZipStream extends Readable {
39
private workingFile: Readable | undefined
40
private finished = false
@@ -93,22 +94,22 @@ export class QuickZipStream extends Readable {
94
95
async calculateSize(howLong:number = 1000) {
96
const endBy = Date.now() + howLong
96
- while (1) {
97
+ for await (const value of this.walker) { // getting the entries is the slow part
98
if (Date.now() >= endBy)
99
return NaN
99
- const { value } = await this.walker.next()
100
- if (!value) break
101
- this.consumedCalculating.push(value) // we keep same shape of the generator, so
100
+ this.consumedCalculating.push(value) // keep the same shape of the generator, so
101
}
102
// if we reach here, then we were able to consume all entries of the walker (in time)
103
let offset = 0
104
let centralDirSize = 0
105
for (const file of this.consumedCalculating) {
106
const pathSize = Buffer.from(file.path, 'utf8').length
108
- const { size=0 } = file
107
+ const { size=0, getData } = file
108
const extraLength = (size > ZIP64_SIZE_LIMIT ? 2 : 0) + (offset > ZIP64_SIZE_LIMIT ? 1 : 0)
109
const extraDataSize = extraLength && (2+2 + extraLength*8)
110
offset += 4+2+2+2+ 4+4+4+4+ 2+2+ pathSize + size
111
+ if (getData)
112
+ offset += 4+4+2*(size > ZIP64_SIZE_LIMIT ? 8 : 4)
113
centralDirSize += 4+2+2+2+2+ 4+4+4+4+ 2+2+2+2+2+ 4+4 + pathSize + extraDataSize
114
}
115
const n = this.consumedCalculating.length
@@ -139,11 +140,12 @@ export class QuickZipStream extends Readable {
140
2, FLAGS,
141
2, 0, // compression = store
142
...ts2buf(ts || this.now),
143
+ // in our mode, crc and sizes are zero in local file header, and written in the data-descriptor after the file data, and in the central directory
144
4, 0, // crc
143
- 4, 0, // size
144
- 4, 0, // size
145
+ 4, 0, // compressed size
146
+ 4, 0, // uncompressed size
147
2, pathAsBuffer.length,
146
- 2, 0, // extra length
148
+ 2, 0, // length of the extra field
149
])
150
this.controlledPush(pathAsBuffer)
151
if (this.finished) return
@@ -153,43 +155,50 @@ export class QuickZipStream extends Readable {
155
let crc = cacheHit ? cache!.crc : getData ? crc32function('') : 0
156
const extAttr = !mode ? 0 : (mode | 0x8000) * 0x10000 // it's like <<16 but doesn't overflow so easily
157
const entry = { size, crc, pathAsBuffer, ts, offset, version, extAttr }
156
- if (this.skip >= size && cacheHit) {
157
- this.skip -= size
158
- this.dataWritten += size
158
+ if (!getData) {
159
this.entries.push(entry)
160
return this.continuePiping()
161
}
162
- if (!getData) {
162
+ if (this.skip >= size && cacheHit) {
163
+ this.skip -= size
164
+ this.dataWritten += size
165
this.entries.push(entry)
164
- return this.continuePiping()
166
}
166
- const data = getData()
167
- data.on('error', (err) => {
168
- if ((err as any)?.code !== 'EACCES')
169
- console.error('zipping:', String(err))
170
- data.destroy(err)
171
- this.workingFile = undefined
172
- this.continuePiping()
167
+ else await new Promise<void>(resolve => {
168
+ const data = this.workingFile = getData()
169
+ data.on('error', (err) => {
170
+ if ((err as any)?.code !== 'EACCES')
171
+ console.error('zipping:', String(err))
172
+ data.destroy(err)
173
+ resolve()
174
+ })
175
+ data.on('end', ()=>{
176
+ entry.crc = crc
177
+ if (sourcePath)
178
+ crcCache[sourcePath] = { ts, crc }
179
+ this.entries.push(entry)
180
+ resolve()
181
+ })
182
+ data.on('data', chunk => {
183
+ if (this.destroyed)
184
+ return data.destroy()
185
+ if (!this.controlledPush(chunk)) // destination buffer full
186
+ data.pause() // slow down
187
+ if (!cacheHit)
188
+ crc = crc32function(chunk, crc)
189
+ if (this.finished)
190
+ return data.destroy()
191
+ })
192
})
174
- data.on('end', ()=>{
175
- entry.crc = crc
176
- if (sourcePath)
177
- crcCache[sourcePath] = { ts, crc }
178
- this.entries.push(entry)
179
- this.workingFile = undefined
193
+ this.workingFile = undefined
194
+ const sizeForSize = size > ZIP64_SIZE_LIMIT ? 8 : 4
195
+ if (this.controlledPush([
196
+ 4, 0x08074b50,
197
+ 4, entry.crc,
198
+ sizeForSize, size,
199
+ sizeForSize, size,
200
+ ]))
201
this.continuePiping()
181
- })
182
- this.workingFile = data
183
- data.on('data', chunk => {
184
- if (this.destroyed)
185
- return data.destroy()
186
- if (!this.controlledPush(chunk)) // destination buffer full
187
- data.pause() // slow down
188
- if (!cacheHit)
189
- crc = crc32function(chunk, crc)
190
- if (this.finished)
191
- return data.destroy()
192
- })
202
}
203
204
closeArchive() {
tests/test.ts
+4
-4
@@ -117,15 +117,15 @@ describe('basics', () => {
117
test('protectFromAbove.list', reqList('/protectFromAbove/child/', { inList:['alfa.txt'] }))
118
test('inheritNegativeMask', reqList('/tests/page', { outList: ['index.html'] }))
119
120
- const zipSize = 13178
121
- const zipOfs = 0x32AF
120
+ const zipSize = 13242
121
+ const zipOfs = 0x32EF
122
const zipLength = 4
123
test('zip.head', req('/f1/?get=zip', { empty:true, length:zipSize }, { method:'HEAD' }) )
124
test('zip.partial', req('/f1/?get=zip', { re:/^page$/, length: zipLength }, { headers: { Range: `bytes=${zipOfs}-${zipOfs+zipLength-1}` } }) )
125
test('zip.partial.resume', req('/f1/?get=zip', { re:/^page/, length:zipSize-zipOfs }, { headers: { Range: `bytes=${zipOfs}-` } }) )
126
test('zip.partial.end', req('/f1/f2/?get=zip', { re:/^6/, length:10 }, { headers: { Range: 'bytes=-10' } }) )
127
- test('zip.alfa is forbidden', req('/protectFromAbove/child/?get=zip&list=alfa.txt//renamed', { empty: true, length:118 }, { method:'HEAD' }))
128
- test('zip.cantReadPage', req('/cantReadPage/?get=zip', { length: 4800 }, { method:'HEAD' }))
127
+ test('zip.alfa is forbidden', req('/protectFromAbove/child/?get=zip&list=alfa.txt//renamed', { empty: true, length:134 }, { method:'HEAD' }))
128
+ test('zip.cantReadPage', req('/cantReadPage/?get=zip', { length: 4832 }, { method:'HEAD' }))
129
130
test('referer', req('/f1/page/gpl.png', 403, {
131
headers: { Referer: 'https://some-website.com/try-to-trick/x.com/' }