From 237b450567a48c73ea8da2f89c72988b9200cd34 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 20 Jun 2026 12:54:28 +0200 Subject: [PATCH 1/3] zlib: reject trailing gzip members in web streams Pass the existing rejectGarbageAfterEnd option through to the native zlib context and skip gunzip's concatenated-member loop when it is set. This lets DecompressionStream reject a second gzip member as trailing input while preserving default zlib gunzip behavior. Also make the sync zlib path honor rejectGarbageAfterEnd when native decompression leaves unused input, covering Brotli as well. Fixes: https://github.com/nodejs/node/issues/58247 Signed-off-by: Filip Skokan --- lib/zlib.js | 8 +++- src/node_zlib.cc | 37 +++++++++++----- test/parallel/test-zlib-type-error.js | 63 ++++++++++++++++++++------- 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/lib/zlib.js b/lib/zlib.js index d4f2446a5976cb..342d210963987e 100644 --- a/lib/zlib.js +++ b/lib/zlib.js @@ -472,6 +472,11 @@ function processChunkSync(self, chunk, flushFlag) { } } + if (availInAfter > 0 && self._rejectGarbageAfterEnd) { + _close(self); + throw new ERR_TRAILING_JUNK_AFTER_STREAM_END(); + } + self.bytesWritten = inputRead; _close(self); @@ -678,7 +683,8 @@ function Zlib(opts, mode) { strategy, this._writeState, processCallback, - dictionary); + dictionary, + opts?.rejectGarbageAfterEnd === true); ZlibBase.call(this, opts, mode, handle, zlibDefaultOpts); diff --git a/src/node_zlib.cc b/src/node_zlib.cc index 9774c847ee50da..95201624cfa2fa 100644 --- a/src/node_zlib.cc +++ b/src/node_zlib.cc @@ -196,6 +196,7 @@ class ZlibContext final : public MemoryRetainer { int window_bits, int mem_level, int strategy, + bool reject_garbage_after_end, std::vector&& dictionary); CompressionError SetParams(int level, int strategy); @@ -223,6 +224,7 @@ class ZlibContext final : public MemoryRetainer { node_zlib_mode mode_ = NONE; int strategy_ = 0; int window_bits_ = 0; + bool reject_garbage_after_end_ = false; unsigned int gzip_id_bytes_read_ = 0; std::vector dictionary_; @@ -749,9 +751,10 @@ class ZlibStream final : public CompressionStream { "a version of npm (> 5.5.1 or < 5.4.0) or node-tar (> 4.0.1) " "that is compatible with Node.js 9 and above.\n"); } - CHECK(args.Length() == 7 && - "init(windowBits, level, memLevel, strategy, writeResult, writeCallback," - " dictionary)"); + CHECK((args.Length() == 7 || args.Length() == 8) && + "init(windowBits, level, memLevel, strategy, writeResult, " + "writeCallback," + " dictionary[, rejectGarbageAfterEnd])"); ZlibStream* wrap; ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); @@ -791,10 +794,20 @@ class ZlibStream final : public CompressionStream { data + Buffer::Length(args[6])); } + bool reject_garbage_after_end = false; + if (args.Length() == 8) { + CHECK(args[7]->IsBoolean()); + reject_garbage_after_end = args[7]->IsTrue(); + } + wrap->InitStream(write_result, write_js_callback); AllocScope alloc_scope(wrap); - wrap->context()->Init(level, window_bits, mem_level, strategy, + wrap->context()->Init(level, + window_bits, + mem_level, + strategy, + reject_garbage_after_end, std::move(dictionary)); } @@ -1124,10 +1137,8 @@ void ZlibContext::DoThreadPoolWork() { } } - while (strm_.avail_in > 0 && - mode_ == GUNZIP && - err_ == Z_STREAM_END && - strm_.next_in[0] != 0x00) { + while (strm_.avail_in > 0 && mode_ == GUNZIP && err_ == Z_STREAM_END && + !reject_garbage_after_end_ && strm_.next_in[0] != 0x00) { // Bytes remain in input buffer. Perhaps this is another compressed // member in the same archive, or just trailing garbage. // Trailing zero bytes are okay, though, since they are frequently @@ -1226,9 +1237,12 @@ CompressionError ZlibContext::ResetStream() { return SetDictionary(); } -void ZlibContext::Init( - int level, int window_bits, int mem_level, int strategy, - std::vector&& dictionary) { +void ZlibContext::Init(int level, + int window_bits, + int mem_level, + int strategy, + bool reject_garbage_after_end, + std::vector&& dictionary) { // Set allocation functions strm_.zalloc = CompressionStreamMemoryOwner::AllocForZlib; strm_.zfree = CompressionStreamMemoryOwner::FreeForZlib; @@ -1259,6 +1273,7 @@ void ZlibContext::Init( window_bits_ = window_bits; mem_level_ = mem_level; strategy_ = strategy; + reject_garbage_after_end_ = reject_garbage_after_end; flush_ = Z_NO_FLUSH; diff --git a/test/parallel/test-zlib-type-error.js b/test/parallel/test-zlib-type-error.js index 912b59fd9ca923..ab2bdfb1b777a3 100644 --- a/test/parallel/test-zlib-type-error.js +++ b/test/parallel/test-zlib-type-error.js @@ -2,36 +2,67 @@ require('../common'); const assert = require('assert'); const test = require('node:test'); +const zlib = require('zlib'); const { DecompressionStream } = require('stream/web'); -test('DecompressStream deflat emits error on trailing data', async () => { +async function assertDecompressionStreamRejects(format, chunks) { + await assert.rejects( + Array.fromAsync( + new Blob(chunks).stream().pipeThrough(new DecompressionStream(format)) + ), + { name: 'TypeError' }, + ); +} + +test('DecompressionStream deflate emits TypeError on trailing data', async () => { const valid = new Uint8Array([120, 156, 75, 4, 0, 0, 98, 0, 98]); // deflate('a') const empty = new Uint8Array(1); const invalid = new Uint8Array([...valid, ...empty]); const double = new Uint8Array([...valid, ...valid]); - for (const chunk of [[invalid], [valid, empty], [valid, valid], [valid, double]]) { - await assert.rejects( - Array.fromAsync( - new Blob([chunk]).stream().pipeThrough(new DecompressionStream('deflate')) - ), - { name: 'TypeError' }, - ); + for (const chunks of [[invalid], [valid, empty], [valid, valid], [double]]) { + await assertDecompressionStreamRejects('deflate', chunks); } }); -test('DecompressStream gzip emits error on trailing data', async () => { +test('DecompressionStream gzip emits TypeError on trailing data', async () => { const valid = new Uint8Array([31, 139, 8, 0, 0, 0, 0, 0, 0, 19, 75, 4, 0, 67, 190, 183, 232, 1, 0, 0, 0]); // gzip('a') const empty = new Uint8Array(1); const invalid = new Uint8Array([...valid, ...empty]); const double = new Uint8Array([...valid, ...valid]); - for (const chunk of [[invalid], [valid, empty], [valid, valid], [double]]) { - await assert.rejects( - Array.fromAsync( - new Blob([chunk]).stream().pipeThrough(new DecompressionStream('gzip')) - ), - { name: 'TypeError' }, - ); + for (const chunks of [[invalid], [valid, empty], [valid, valid], [double]]) { + await assertDecompressionStreamRejects('gzip', chunks); + } +}); + +test('DecompressionStream brotli emits TypeError on trailing data', async () => { + const valid = zlib.brotliCompressSync(Buffer.from('a')); + const empty = new Uint8Array(1); + const invalid = new Uint8Array([...valid, ...empty]); + const double = new Uint8Array([...valid, ...valid]); + for (const chunks of [[invalid], [valid, empty], [valid, valid], [double]]) { + await assertDecompressionStreamRejects('brotli', chunks); } }); + +test('zlib sync decompression honors rejectGarbageAfterEnd', () => { + const valid = new Uint8Array([31, 139, 8, 0, 0, 0, 0, 0, 0, 19, 75, 4, + 0, 67, 190, 183, 232, 1, 0, 0, 0]); // gzip('a') + const double = new Uint8Array([...valid, ...valid]); + + assert.deepStrictEqual(zlib.gunzipSync(double), Buffer.from('aa')); + assert.throws( + () => zlib.gunzipSync(double, { rejectGarbageAfterEnd: true }), + { code: 'ERR_TRAILING_JUNK_AFTER_STREAM_END', name: 'TypeError' }, + ); + + const brotli = zlib.brotliCompressSync(Buffer.from('a')); + const brotliDouble = Buffer.concat([brotli, brotli]); + + assert.deepStrictEqual(zlib.brotliDecompressSync(brotliDouble), Buffer.from('a')); + assert.throws( + () => zlib.brotliDecompressSync(brotliDouble, { rejectGarbageAfterEnd: true }), + { code: 'ERR_TRAILING_JUNK_AFTER_STREAM_END', name: 'TypeError' }, + ); +}); From a47d32dc1132a20d0fa7ed199eaea2ca9a5fdade Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 20 Jun 2026 13:09:10 +0200 Subject: [PATCH 2/3] zlib: expose rejectGarbageAfterEnd option Document rejectGarbageAfterEnd as a public decompression option and validate it as a boolean. Add coverage for stream, async convenience, and sync convenience APIs across zlib, gzip, Brotli, and Zstd-backed decompression. Signed-off-by: Filip Skokan --- doc/api/zlib.md | 19 +++ lib/zlib.js | 8 + .../test-zlib-reject-garbage-after-end.js | 144 ++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 test/parallel/test-zlib-reject-garbage-after-end.js diff --git a/doc/api/zlib.md b/doc/api/zlib.md index e0516e923cb502..4ac10642a66102 100644 --- a/doc/api/zlib.md +++ b/doc/api/zlib.md @@ -801,6 +801,9 @@ These advanced options are available for controlling decompression: @@ -1102,6 +1118,9 @@ Each Zstd-based class takes an `options` object. All options are optional. * `dictionary` {Buffer} Optional dictionary used to improve compression efficiency when compressing or decompressing data that shares common patterns with the dictionary. +* `rejectGarbageAfterEnd` {boolean} If `true`, decompression fails when + trailing input is detected after the end of the compressed stream. + **Default:** `false` For example: diff --git a/lib/zlib.js b/lib/zlib.js index 342d210963987e..8ebc4fde142876 100644 --- a/lib/zlib.js +++ b/lib/zlib.js @@ -65,6 +65,7 @@ const { const { owner_symbol } = require('internal/async_hooks').symbols; const { checkRangesOrGetDefault, + validateBoolean, validateFunction, validateUint32, validateFiniteNumber, @@ -246,6 +247,13 @@ function ZlibBase(opts, mode, handle, { flush, finishFlush, fullFlush }) { opts.maxOutputLength, 'options.maxOutputLength', 1, kMaxLength, kMaxLength); + if (opts.rejectGarbageAfterEnd !== undefined) { + validateBoolean( + opts.rejectGarbageAfterEnd, + 'options.rejectGarbageAfterEnd', + ); + } + if (opts.encoding || opts.objectMode || opts.writableObjectMode) { opts = { ...opts }; opts.encoding = null; diff --git a/test/parallel/test-zlib-reject-garbage-after-end.js b/test/parallel/test-zlib-reject-garbage-after-end.js new file mode 100644 index 00000000000000..8039865f5f1193 --- /dev/null +++ b/test/parallel/test-zlib-reject-garbage-after-end.js @@ -0,0 +1,144 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const test = require('node:test'); +const { finished } = require('stream/promises'); +const zlib = require('zlib'); + +const trailingJunkError = { + code: 'ERR_TRAILING_JUNK_AFTER_STREAM_END', + name: 'TypeError', +}; + +function callAsync(fn, input, options) { + return new Promise((resolve, reject) => { + fn(input, options, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); +} + +async function collect(stream, input) { + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + stream.end(input); + await finished(stream); + return Buffer.concat(chunks); +} + +const cases = [ + { + label: 'inflate', + compress: zlib.deflateSync, + decompress: zlib.inflate, + decompressSync: zlib.inflateSync, + createDecompress: zlib.createInflate, + defaultOutput: 'a', + }, + { + label: 'inflateRaw', + compress: zlib.deflateRawSync, + decompress: zlib.inflateRaw, + decompressSync: zlib.inflateRawSync, + createDecompress: zlib.createInflateRaw, + defaultOutput: 'a', + }, + { + label: 'gunzip', + compress: zlib.gzipSync, + decompress: zlib.gunzip, + decompressSync: zlib.gunzipSync, + createDecompress: zlib.createGunzip, + defaultOutput: 'aa', + }, + { + label: 'unzip', + compress: zlib.gzipSync, + decompress: zlib.unzip, + decompressSync: zlib.unzipSync, + createDecompress: zlib.createUnzip, + defaultOutput: 'aa', + }, + { + label: 'brotli', + compress: zlib.brotliCompressSync, + decompress: zlib.brotliDecompress, + decompressSync: zlib.brotliDecompressSync, + createDecompress: zlib.createBrotliDecompress, + defaultOutput: 'a', + }, + { + label: 'zstd', + compress: zlib.zstdCompressSync, + decompress: zlib.zstdDecompress, + decompressSync: zlib.zstdDecompressSync, + createDecompress: zlib.createZstdDecompress, + defaultOutput: 'a', + }, +]; + +for (const { + label, + compress, + decompress, + decompressSync, + createDecompress, + defaultOutput, +} of cases) { + test(`rejectGarbageAfterEnd rejects trailing input for ${label}`, async () => { + const compressed = compress(Buffer.from('a')); + const withTrailingInput = Buffer.concat([compressed, compressed]); + + assert.strictEqual(decompressSync(withTrailingInput).toString(), defaultOutput); + assert.strictEqual( + (await callAsync(decompress, withTrailingInput)).toString(), + defaultOutput, + ); + assert.strictEqual( + (await collect(createDecompress(), withTrailingInput)).toString(), + defaultOutput, + ); + + assert.throws( + () => decompressSync(withTrailingInput, { rejectGarbageAfterEnd: true }), + trailingJunkError, + ); + await assert.rejects( + callAsync(decompress, withTrailingInput, { rejectGarbageAfterEnd: true }), + trailingJunkError, + ); + await assert.rejects( + collect( + createDecompress({ rejectGarbageAfterEnd: true }), + withTrailingInput, + ), + trailingJunkError, + ); + }); +} + +test('rejectGarbageAfterEnd must be a boolean', () => { + const compressed = zlib.deflateSync(Buffer.from('a')); + + for (const value of [1, 'true', null]) { + assert.throws( + () => zlib.inflateSync(compressed, { rejectGarbageAfterEnd: value }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }, + ); + assert.throws( + () => zlib.createInflate({ rejectGarbageAfterEnd: value }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }, + ); + } +}); From 6f56f8433e62d85be8736de7845d1e4b304380ca Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 20 Jun 2026 18:19:25 +0200 Subject: [PATCH 3/3] fixup! zlib: expose rejectGarbageAfterEnd option --- doc/api/zlib.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/doc/api/zlib.md b/doc/api/zlib.md index 4ac10642a66102..3f32a7507b1fa0 100644 --- a/doc/api/zlib.md +++ b/doc/api/zlib.md @@ -840,8 +840,9 @@ ignored by the decompression classes. * `maxOutputLength` {integer} Limits output size when using [convenience methods][]. **Default:** [`buffer.kMaxLength`][] * `rejectGarbageAfterEnd` {boolean} If `true`, decompression fails when - trailing input is detected after the end of the compressed stream. - **Default:** `false` + trailing input is detected after the end of the compressed stream. This + includes unreadable bytes and, when decompressing gzip, additional gzip + members following the first member. **Default:** `false` See the [`deflateInit2` and `inflateInit2`][] documentation for more information. @@ -873,8 +874,7 @@ Each Brotli-based class takes an `options` object. All options are optional. [convenience methods][]. **Default:** [`buffer.kMaxLength`][] * `info` {boolean} If `true`, returns an object with `buffer` and `engine`. **Default:** `false` * `rejectGarbageAfterEnd` {boolean} If `true`, decompression fails when - trailing input is detected after the end of the compressed stream. - **Default:** `false` + input remains after the first complete compressed stream. **Default:** `false` For example: @@ -1119,8 +1119,7 @@ Each Zstd-based class takes an `options` object. All options are optional. improve compression efficiency when compressing or decompressing data that shares common patterns with the dictionary. * `rejectGarbageAfterEnd` {boolean} If `true`, decompression fails when - trailing input is detected after the end of the compressed stream. - **Default:** `false` + input remains after the first complete compressed stream. **Default:** `false` For example: