How to check archive expansion limits on upload
Upload an archive that expands far past its own weight and read the status. curl --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=capped" returned 413 with ERR_BUFFER_TOO_LARGE where a limit was set, and 201 with 104,857,600 bytes expanded where none was. The same 101,941-byte upload produced both answers.
Why check this
An upload route that opens what it receives has two sizes to bound, and most bound one. Every framework ships a body limit. A limit on what those bytes become after decompression is code somebody has to write. The fixture below weighs 101,941 bytes and holds 104,857,600: it passes a 1 MiB upload limit, then asks the process for 100 MB.
Run this before sign-off on any endpoint that takes an archive, a compressed backup or a gzipped import file, and again after a change to the parsing library. The failure it prevents is one request that ends the service: the handler allocates the whole expansion at once and the container hits its memory limit.
Three questions, in order: is there a bound, what comes back when it is hit, and is the partial output removed. Listing an archive before extracting is a separate read, in How to check zip file contents.
Prerequisites
- curl 8.0 or later.
--data-binary @filesends the file as the request body, unchanged. - Node 22 for the target below. Point the commands at your own route and expect its status codes.
- Every step runs against
127.0.0.1. Never send a bomb to a host you do not own, and never expand one without a cap. - The target answers in JSON and accepts
?mode=naive,?mode=capped,?mode=stream,?mode=guardand?mode=guard-clean, so one fixture reads five extraction strategies. Save it asarchive-server.mjsand runnode archive-server.mjs.
import { createServer } from 'node:http';
import { createWriteStream, existsSync, statSync, unlinkSync } from 'node:fs';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createGunzip, gunzipSync } from 'node:zlib';
const WIRE_CAP = 1024 * 1024; // what the upload may weigh
const OUTPUT_CAP = 8 * 1024 * 1024; // what it may expand to
const OUT = 'extracted.bin';
const meter = () => {
let n = 0;
return new Transform({
transform(c, _enc, cb) {
n += c.length;
if (n > OUTPUT_CAP) { cb(Object.assign(new Error('expansion cap'), { code: 'EXPANSION_CAP' })); return; }
cb(null, c);
},
});
};
createServer(async (req, res) => {
const mode = new URL(req.url, 'http://127.0.0.1:8934').searchParams.get('mode') || 'naive';
const chunks = [];
let wire = 0;
for await (const c of req) {
wire += c.length;
if (wire > WIRE_CAP) { res.writeHead(413).end('over wire cap\n'); return; }
chunks.push(c);
}
const raw = Buffer.concat(chunks);
const say = (code, body) => {
res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify({ mode, wire, ...body }) + '\n');
};
const rss0 = process.memoryUsage().rss;
try {
if (mode === 'naive') {
const out = gunzipSync(raw);
say(201, { expanded: out.length, rssGrowthMB: +((process.memoryUsage().rss - rss0) / 1048576).toFixed(1) });
} else if (mode === 'capped') {
say(201, { expanded: gunzipSync(raw, { maxOutputLength: OUTPUT_CAP }).length });
} else if (mode === 'stream') {
await pipeline(Readable.from(raw), createGunzip({ maxOutputLength: OUTPUT_CAP }), createWriteStream(OUT));
say(201, { expanded: statSync(OUT).size });
} else {
await pipeline(Readable.from(raw), createGunzip(), meter(), createWriteStream(OUT));
say(201, { expanded: statSync(OUT).size });
}
} catch (e) {
if (mode === 'guard-clean' && existsSync(OUT)) unlinkSync(OUT);
say(413, { code: e.code, leftOnDisk: existsSync(OUT) ? statSync(OUT).size : null });
}
}).listen(8934, '127.0.0.1', () => console.log('archive target on 8934'));
- One honest archive to compare against: any text file under the cap, saved as
honest.txt. The run used 32,354 bytes of Markdown.
node -e "
const fs = require('node:fs'), { gzipSync } = require('node:zlib');
fs.writeFileSync('honest.gz', gzipSync(fs.readFileSync('honest.txt'), { level: 9 }));
console.log('honest.gz', fs.statSync('honest.gz').size, 'bytes from', fs.statSync('honest.txt').size);
"
- Delete
bomb.gz,honest.gzandextracted.binwhen you finish. The fixture belongs in a scratch directory, not in a repository.
Steps
- Step 1.
Build the fixture inside a bound you set.
Buffer.allocfixes the uncompressed size at 100 MiB before compression.node -e " const { gzipSync } = require('node:zlib'); const raw = Buffer.alloc(100 * 1024 * 1024, 0); const gz = gzipSync(raw, { level: 9 }); require('node:fs').writeFileSync('bomb.gz', gz); console.log('uncompressed', raw.length); console.log('compressed ', gz.length); console.log('ratio ', (raw.length / gz.length).toFixed(1) + ':1'); "uncompressed 104857600 compressed 101941 ratio 1028.6:1A hundred kilobytes of upload carry a hundred megabytes of content.
- Step 2.
Read what each file declares before opening it. A gzip member's last four bytes hold its uncompressed size.
node -e " for (const f of ['bomb.gz', 'honest.gz']) { const b = require('node:fs').readFileSync(f); console.log(f, 'on disk', b.length, 'declares', b.readUInt32LE(b.length - 4)); } "bomb.gz on disk 101941 declares 104857600 honest.gz on disk 14073 declares 32354A first filter, not a bound: those four bytes were chosen by whoever built the file.
- Step 3.
Send the fixture to the route that extracts with no limit.
curl -s --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=naive" -w 'HTTP %{http_code}\n'{"mode":"naive","wire":101941,"expanded":104857600,"rssGrowthMB":207} HTTP 201wireis what the body limit saw,expandedis what the process allocated. Resident memory grew by 207 MB for a 100 MB output: the copy and zlib's buffer are held at once. - Step 4.
Send the same file to the route that passes a limit to zlib.
curl -s --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=capped" -w 'HTTP %{http_code}\n'{"mode":"capped","wire":101941,"code":"ERR_BUFFER_TOO_LARGE","leftOnDisk":null} HTTP 413ERR_BUFFER_TOO_LARGEis what Node's zlib raises whenmaxOutputLengthis passed. The allocation never happened. - Step 5.
Send the honest archive to the same route, to show what the limit lets through.
curl -s --data-binary @honest.gz "http://127.0.0.1:8934/upload?mode=capped" -w 'HTTP %{http_code}\n'{"mode":"capped","wire":14073,"expanded":32354} HTTP 201A limit that rejects this archive is set too low.
- Step 6.
Send the fixture to the route that streams to disk with the same option.
curl -s --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=stream" -w 'HTTP %{http_code}\n'{"mode":"stream","wire":101941,"expanded":104857600} HTTP 201The option that stopped step 4 stopped nothing here.
expandedcomes fromstatSyncon the written file: 104,857,600 bytes under a cap of 8,388,608. - Step 7.
Send it to the route that counts the bytes leaving the decompressor.
curl -s --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=guard" -w 'HTTP %{http_code}\n'{"mode":"guard","wire":101941,"code":"EXPANSION_CAP","leftOnDisk":8388608} HTTP 413The status is right and the disk is not.
leftOnDiskis 8,388,608 bytes of a refused upload that nothing removes. - Step 8.
Send it to the route that unlinks the partial file in the error path.
curl -s --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=guard-clean" -w 'HTTP %{http_code}\n'{"mode":"guard-clean","wire":101941,"code":"EXPANSION_CAP","leftOnDisk":null} HTTP 413Same status, same code, nothing left behind. One line of server code separates it from step 7.
How to read the result
| What you see | What it means | What to do | | --- | --- | --- | | 201 and an expanded size far above the upload size | Nothing bounds expansion | Raise it as a defect and attach the ratio from step 1. | | 413 or 400 with a code naming the limit | Expansion is bounded | Repeat with the honest archive, as in step 5, to find where the limit sits. | | 413 and a partial file still present | The bound fires and the cleanup does not | Report the leftover path and size. Repeated uploads fill the volume. | | 500, or the connection drops with no status | The process hit its own ceiling | Read the service log for an out of memory kill. This is the outage the check is for. | | The same file rejected before extraction starts | A body limit answered, not an expansion limit | Shrink the fixture below the body limit and run it again. |
Common mistakes
Thresholds
What to check next
- How to check zip file contents: the entry list, read before extraction.
- How to test file upload size limit: the body limit this check stays under.
- How to check if file type is validated on the server: whether the route treats the upload as an archive.
- How to check sha256 of a file: whether an accepted archive was stored as sent.
FAQ
What is a zip bomb?
An archive whose contents are far larger than the archive. Compression is good at repetition: 104,857,600 zero bytes gzipped to 101,941 in step 1. Whatever opens it without a limit does the damage.
How do I test a zip bomb safely?
Fix the uncompressed size in advance, as step 1 does with Buffer.alloc, keep it on a local target, and delete it after the run. A fixture expanding to 100 MB proves the same point as one expanding to a terabyte.
Does a file size limit stop a decompression bomb?
No. Every run above weighed 101,941 bytes on the wire. The limit that matters is on the decompressor's output, enforced while extraction runs.
Which status code should the service return?
413 with a code in the body naming the limit, as in steps 4, 7 and 8. A 500 means the process was surprised. More important than the number: the rejection leaves no partial file.
Does this apply to zip as well as gzip?
Yes. A zip declares an uncompressed size per entry, written by whoever built the file, like the gzip trailer in step 2. Extraction still needs a counter.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny