Commit ca0810fd80e for nodejs
commit ca0810fd80e11e494585c641158e57982cd7329c
Author: Xia Chao <shapirolutts@gmail.com>
Date: Sat Sep 26 11:27:53 2026 -0700
zlib: reject reset after gzip/deflate emitted incomplete output
deflateReset starts a new member. Bytes already written out cannot be
taken back, so gunzip/inflate see a truncated member followed by a new
header. Refuse reset in that case. Raw deflate has no wrapper header
and is unchanged.
Signed-off-by: Xia Chao <shapirolutts@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/66179
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
diff --git a/doc/api/zlib.md b/doc/api/zlib.md
index 3664cb6080d..72f8223acdb 100644
--- a/doc/api/zlib.md
+++ b/doc/api/zlib.md
@@ -2198,6 +2198,15 @@ For Zstd streams, cancel the current frame and start a new session while
preserving the configured parameters and dictionary. If `pledgedSrcSize` was
configured for a Zstd compressor, it applies again to the next frame.
+Resetting a gzip stream after it has emitted output for an incomplete member
+causes the stream to error with `ERR_ZLIB_INCOMPLETE_FRAME`. Resetting at
+that point would discard the member state while the bytes already written out
+remain at the start of the output stream, leaving it undecodable. Call
+`.end()`, or start over with a new gzip stream, instead.
+zlib-wrapped deflate may still `reset()` after a flush; callers that reuse
+the compressor discard the first output. Raw deflate has no wrapper header,
+so `reset()` after a flush still concatenates.
+
Calling `reset()` while a write is in progress throws an `Error`.
Resetting an incomplete Zstd compression frame after it has emitted output
diff --git a/src/node_zlib.cc b/src/node_zlib.cc
index 7d6ed93242d..82a88bd98f7 100644
--- a/src/node_zlib.cc
+++ b/src/node_zlib.cc
@@ -229,6 +229,15 @@ class ZlibContext final : public MemoryRetainer {
unsigned int gzip_id_bytes_read_ = 0;
std::vector<unsigned char> dictionary_;
+ // gzip and zlib-wrapped deflate emit a header on the first deflate() call.
+ // Resetting after those bytes have left the compressor starts a new member
+ // while the fragment remains, so gunzip/inflate fail with Z_DATA_ERROR.
+ // Raw deflate has no header; Z_FULL_FLUSH + reset still concatenates.
+ // A member is complete once deflate() has been called with Z_FINISH and
+ // returned Z_STREAM_END.
+ bool stream_complete_ = true;
+ bool output_emitted_ = false;
+
z_stream strm_;
};
@@ -1096,9 +1105,20 @@ void ZlibContext::DoThreadPoolWork() {
switch (mode_) {
case DEFLATE:
case GZIP:
- case DEFLATERAW:
+ case DEFLATERAW: {
+ const unsigned out_before = strm_.avail_out;
err_ = deflate(&strm_, flush_);
+ if (out_before > strm_.avail_out) {
+ output_emitted_ = true;
+ }
+ if (err_ == Z_STREAM_END) {
+ stream_complete_ = true;
+ output_emitted_ = false;
+ } else if (err_ == Z_OK || err_ == Z_BUF_ERROR) {
+ stream_complete_ = false;
+ }
break;
+ }
case UNZIP:
if (strm_.avail_in > 0) {
next_expected_header_byte = strm_.next_in;
@@ -1241,6 +1261,20 @@ CompressionError ZlibContext::GetErrorInfo() const {
CompressionError ZlibContext::ResetStream() {
+ // deflateReset() is deflateEnd + deflateInit: a new stream. gzip emits a
+ // wrapper header on the first write; those bytes cannot be taken back, so
+ // refuse reset once an incomplete gzip member has emitted output.
+ // zlib-wrapped deflate still allows reset after flush: callers discard the
+ // first member (test-zlib-dictionary.js). Raw deflate has no wrapper header,
+ // so flush+reset still concatenates.
+ if (mode_ == GZIP && !stream_complete_ && output_emitted_) {
+ return CompressionError(
+ "Cannot reset a gzip stream with an incomplete member; end the "
+ "stream or start a new gzip compressor",
+ "ERR_ZLIB_INCOMPLETE_FRAME",
+ Z_STREAM_ERROR);
+ }
+
bool first_init_call = InitZlib();
if (first_init_call && err_ != Z_OK) {
return ErrorForMessage("Failed to init stream before reset");
@@ -1266,6 +1300,8 @@ CompressionError ZlibContext::ResetStream() {
if (err_ != Z_OK)
return ErrorForMessage("Failed to reset stream");
+ stream_complete_ = true;
+ output_emitted_ = false;
return SetDictionary();
}
@@ -1310,6 +1346,8 @@ void ZlibContext::Init(int level,
flush_ = Z_NO_FLUSH;
err_ = Z_OK;
+ stream_complete_ = true;
+ output_emitted_ = false;
if (mode_ == GZIP || mode_ == GUNZIP) {
window_bits_ += 16;
diff --git a/test/parallel/test-zlib-reset-incomplete-output.js b/test/parallel/test-zlib-reset-incomplete-output.js
new file mode 100644
index 00000000000..84190b32d52
--- /dev/null
+++ b/test/parallel/test-zlib-reset-incomplete-output.js
@@ -0,0 +1,154 @@
+'use strict';
+
+// Tests that reset() refuses to run on gzip once an incomplete member has
+// already emitted output.
+//
+// deflateReset() is equivalent to deflateEnd + deflateInit. gzip writes a
+// header on the first write, so those bytes cannot be taken back.
+//
+// zlib-wrapped deflate still allows reset after flush: official
+// test-zlib-dictionary.js discards the first member and reuses the
+// compressor. Raw deflate has no wrapper header: a small write may emit
+// nothing, and flush+reset still concatenates.
+
+require('../common');
+const assert = require('assert');
+const { finished } = require('stream/promises');
+const test = require('node:test');
+const zlib = require('zlib');
+
+async function writeHello(stream) {
+ await new Promise((resolve, reject) => {
+ stream.write(Buffer.from('hello'), (err) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve();
+ }
+ });
+ });
+}
+
+test('Gzip reset throws when write has emitted wrapper output', async () => {
+ const stream = zlib.createGzip();
+ const chunks = [];
+ stream.on('data', (chunk) => chunks.push(chunk));
+
+ await writeHello(stream);
+ assert.ok(Buffer.concat(chunks).length > 0);
+
+ stream.reset();
+ stream.end(Buffer.from('world'));
+
+ await assert.rejects(finished(stream), {
+ code: 'ERR_ZLIB_INCOMPLETE_FRAME',
+ });
+});
+
+test('Gzip reset throws when flush has emitted incomplete output', async () => {
+ const stream = zlib.createGzip();
+ const chunks = [];
+ stream.on('data', (chunk) => chunks.push(chunk));
+
+ stream.write(Buffer.from('hello'));
+ await new Promise((resolve) => stream.flush(resolve));
+ assert.ok(Buffer.concat(chunks).length > 0);
+
+ stream.reset();
+ stream.end(Buffer.from('world'));
+
+ await assert.rejects(finished(stream), {
+ code: 'ERR_ZLIB_INCOMPLETE_FRAME',
+ });
+});
+
+test('Gzip flush followed by end still produces a valid stream', async () => {
+ const stream = zlib.createGzip();
+ const chunks = [];
+ stream.on('data', (chunk) => chunks.push(chunk));
+
+ stream.write(Buffer.from('hello'));
+ await new Promise((resolve) => stream.flush(resolve));
+ stream.end(Buffer.from('world'));
+ await finished(stream);
+
+ assert.strictEqual(
+ zlib.gunzipSync(Buffer.concat(chunks)).toString(),
+ 'helloworld',
+ );
+});
+
+test('Gzip reset before any write still works', async () => {
+ const stream = zlib.createGzip();
+ const chunks = [];
+ stream.on('data', (chunk) => chunks.push(chunk));
+
+ stream.reset();
+ stream.end(Buffer.from('hello'));
+ await finished(stream);
+
+ assert.strictEqual(
+ zlib.gunzipSync(Buffer.concat(chunks)).toString(),
+ 'hello',
+ );
+});
+
+test('Deflate reset after flush still works when first output is discarded',
+ async () => {
+ const stream = zlib.createDeflate();
+ const chunks = [];
+ let take = false;
+ stream.on('data', (chunk) => {
+ if (take) {
+ chunks.push(chunk);
+ }
+ });
+
+ stream.write(Buffer.from('hello'));
+ await new Promise((resolve) => stream.flush(resolve));
+ stream.reset();
+ take = true;
+ stream.end(Buffer.from('world'));
+ await finished(stream);
+
+ assert.strictEqual(
+ zlib.inflateSync(Buffer.concat(chunks)).toString(),
+ 'world',
+ );
+ });
+
+test('DeflateRaw reset after write without emitted output still works',
+ async () => {
+ const stream = zlib.createDeflateRaw();
+ const chunks = [];
+ stream.on('data', (chunk) => chunks.push(chunk));
+
+ await writeHello(stream);
+ assert.strictEqual(Buffer.concat(chunks).length, 0);
+
+ stream.reset();
+ stream.end(Buffer.from('world'));
+ await finished(stream);
+
+ assert.strictEqual(
+ zlib.inflateRawSync(Buffer.concat(chunks)).toString(),
+ 'world',
+ );
+ });
+
+test('DeflateRaw flush followed by reset still concatenates', async () => {
+ const stream = zlib.createDeflateRaw();
+ const chunks = [];
+ stream.on('data', (chunk) => chunks.push(chunk));
+
+ stream.write(Buffer.from('hello'));
+ await new Promise((resolve) => stream.flush(resolve));
+ stream.reset();
+ stream.end(Buffer.from('world'));
+ await finished(stream);
+
+ assert.strictEqual(
+ zlib.inflateRawSync(Buffer.concat(chunks)).toString(),
+ 'helloworld',
+ );
+});