diff --git a/doc/api/zlib.md b/doc/api/zlib.md index e0516e923cb502..3f32a7507b1fa0 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,8 @@ 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 + input remains after the first complete compressed stream. **Default:** `false` For example: diff --git a/lib/zlib.js b/lib/zlib.js index d4f2446a5976cb..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; @@ -472,6 +480,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 +691,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-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', + }, + ); + } +}); 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' }, + ); +});