@samitouri / QOSami-HFS / commits / 3d1fa5fa

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/' }