Skip to content

Commit 54e9330

Browse files
xia-chaoaduh95
authored andcommitted
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: #66179 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Filip Skokan <panva.ip@gmail.com>
1 parent 480ca7c commit 54e9330

3 files changed

Lines changed: 202 additions & 1 deletion

File tree

‎doc/api/zlib.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2194,6 +2194,15 @@ For Zstd streams, cancel the current frame and start a new session while
21942194
preserving the configured parameters and dictionary. If `pledgedSrcSize` was
21952195
configured for a Zstd compressor, it applies again to the next frame.
21962196

2197+
Resetting a gzip stream after it has emitted output for an incomplete member
2198+
causes the stream to error with `ERR_ZLIB_INCOMPLETE_FRAME`. Resetting at
2199+
that point would discard the member state while the bytes already written out
2200+
remain at the start of the output stream, leaving it undecodable. Call
2201+
`.end()`, or start over with a new gzip stream, instead.
2202+
zlib-wrapped deflate may still `reset()` after a flush; callers that reuse
2203+
the compressor discard the first output. Raw deflate has no wrapper header,
2204+
so `reset()` after a flush still concatenates.
2205+
21972206
Calling `reset()` while a write is in progress throws an `Error`.
21982207

21992208
Resetting an incomplete Zstd compression frame after it has emitted output

‎src/node_zlib.cc‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,15 @@ class ZlibContext final : public MemoryRetainer {
229229
unsigned int gzip_id_bytes_read_ = 0;
230230
std::vector<unsigned char> dictionary_;
231231

232+
// gzip and zlib-wrapped deflate emit a header on the first deflate() call.
233+
// Resetting after those bytes have left the compressor starts a new member
234+
// while the fragment remains, so gunzip/inflate fail with Z_DATA_ERROR.
235+
// Raw deflate has no header; Z_FULL_FLUSH + reset still concatenates.
236+
// A member is complete once deflate() has been called with Z_FINISH and
237+
// returned Z_STREAM_END.
238+
bool stream_complete_ = true;
239+
bool output_emitted_ = false;
240+
232241
z_stream strm_;
233242
};
234243

@@ -1096,9 +1105,20 @@ void ZlibContext::DoThreadPoolWork() {
10961105
switch (mode_) {
10971106
case DEFLATE:
10981107
case GZIP:
1099-
case DEFLATERAW:
1108+
case DEFLATERAW: {
1109+
const unsigned out_before = strm_.avail_out;
11001110
err_ = deflate(&strm_, flush_);
1111+
if (out_before > strm_.avail_out) {
1112+
output_emitted_ = true;
1113+
}
1114+
if (err_ == Z_STREAM_END) {
1115+
stream_complete_ = true;
1116+
output_emitted_ = false;
1117+
} else if (err_ == Z_OK || err_ == Z_BUF_ERROR) {
1118+
stream_complete_ = false;
1119+
}
11011120
break;
1121+
}
11021122
case UNZIP:
11031123
if (strm_.avail_in > 0) {
11041124
next_expected_header_byte = strm_.next_in;
@@ -1241,6 +1261,20 @@ CompressionError ZlibContext::GetErrorInfo() const {
12411261

12421262

12431263
CompressionError ZlibContext::ResetStream() {
1264+
// deflateReset() is deflateEnd + deflateInit: a new stream. gzip emits a
1265+
// wrapper header on the first write; those bytes cannot be taken back, so
1266+
// refuse reset once an incomplete gzip member has emitted output.
1267+
// zlib-wrapped deflate still allows reset after flush: callers discard the
1268+
// first member (test-zlib-dictionary.js). Raw deflate has no wrapper header,
1269+
// so flush+reset still concatenates.
1270+
if (mode_ == GZIP && !stream_complete_ && output_emitted_) {
1271+
return CompressionError(
1272+
"Cannot reset a gzip stream with an incomplete member; end the "
1273+
"stream or start a new gzip compressor",
1274+
"ERR_ZLIB_INCOMPLETE_FRAME",
1275+
Z_STREAM_ERROR);
1276+
}
1277+
12441278
bool first_init_call = InitZlib();
12451279
if (first_init_call && err_ != Z_OK) {
12461280
return ErrorForMessage("Failed to init stream before reset");
@@ -1266,6 +1300,8 @@ CompressionError ZlibContext::ResetStream() {
12661300
if (err_ != Z_OK)
12671301
return ErrorForMessage("Failed to reset stream");
12681302

1303+
stream_complete_ = true;
1304+
output_emitted_ = false;
12691305
return SetDictionary();
12701306
}
12711307

@@ -1310,6 +1346,8 @@ void ZlibContext::Init(int level,
13101346
flush_ = Z_NO_FLUSH;
13111347

13121348
err_ = Z_OK;
1349+
stream_complete_ = true;
1350+
output_emitted_ = false;
13131351

13141352
if (mode_ == GZIP || mode_ == GUNZIP) {
13151353
window_bits_ += 16;
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
'use strict';
2+
3+
// Tests that reset() refuses to run on gzip once an incomplete member has
4+
// already emitted output.
5+
//
6+
// deflateReset() is equivalent to deflateEnd + deflateInit. gzip writes a
7+
// header on the first write, so those bytes cannot be taken back.
8+
//
9+
// zlib-wrapped deflate still allows reset after flush: official
10+
// test-zlib-dictionary.js discards the first member and reuses the
11+
// compressor. Raw deflate has no wrapper header: a small write may emit
12+
// nothing, and flush+reset still concatenates.
13+
14+
require('../common');
15+
const assert = require('assert');
16+
const { finished } = require('stream/promises');
17+
const test = require('node:test');
18+
const zlib = require('zlib');
19+
20+
async function writeHello(stream) {
21+
await new Promise((resolve, reject) => {
22+
stream.write(Buffer.from('hello'), (err) => {
23+
if (err) {
24+
reject(err);
25+
} else {
26+
resolve();
27+
}
28+
});
29+
});
30+
}
31+
32+
test('Gzip reset throws when write has emitted wrapper output', async () => {
33+
const stream = zlib.createGzip();
34+
const chunks = [];
35+
stream.on('data', (chunk) => chunks.push(chunk));
36+
37+
await writeHello(stream);
38+
assert.ok(Buffer.concat(chunks).length > 0);
39+
40+
stream.reset();
41+
stream.end(Buffer.from('world'));
42+
43+
await assert.rejects(finished(stream), {
44+
code: 'ERR_ZLIB_INCOMPLETE_FRAME',
45+
});
46+
});
47+
48+
test('Gzip reset throws when flush has emitted incomplete output', async () => {
49+
const stream = zlib.createGzip();
50+
const chunks = [];
51+
stream.on('data', (chunk) => chunks.push(chunk));
52+
53+
stream.write(Buffer.from('hello'));
54+
await new Promise((resolve) => stream.flush(resolve));
55+
assert.ok(Buffer.concat(chunks).length > 0);
56+
57+
stream.reset();
58+
stream.end(Buffer.from('world'));
59+
60+
await assert.rejects(finished(stream), {
61+
code: 'ERR_ZLIB_INCOMPLETE_FRAME',
62+
});
63+
});
64+
65+
test('Gzip flush followed by end still produces a valid stream', async () => {
66+
const stream = zlib.createGzip();
67+
const chunks = [];
68+
stream.on('data', (chunk) => chunks.push(chunk));
69+
70+
stream.write(Buffer.from('hello'));
71+
await new Promise((resolve) => stream.flush(resolve));
72+
stream.end(Buffer.from('world'));
73+
await finished(stream);
74+
75+
assert.strictEqual(
76+
zlib.gunzipSync(Buffer.concat(chunks)).toString(),
77+
'helloworld',
78+
);
79+
});
80+
81+
test('Gzip reset before any write still works', async () => {
82+
const stream = zlib.createGzip();
83+
const chunks = [];
84+
stream.on('data', (chunk) => chunks.push(chunk));
85+
86+
stream.reset();
87+
stream.end(Buffer.from('hello'));
88+
await finished(stream);
89+
90+
assert.strictEqual(
91+
zlib.gunzipSync(Buffer.concat(chunks)).toString(),
92+
'hello',
93+
);
94+
});
95+
96+
test('Deflate reset after flush still works when first output is discarded',
97+
async () => {
98+
const stream = zlib.createDeflate();
99+
const chunks = [];
100+
let take = false;
101+
stream.on('data', (chunk) => {
102+
if (take) {
103+
chunks.push(chunk);
104+
}
105+
});
106+
107+
stream.write(Buffer.from('hello'));
108+
await new Promise((resolve) => stream.flush(resolve));
109+
stream.reset();
110+
take = true;
111+
stream.end(Buffer.from('world'));
112+
await finished(stream);
113+
114+
assert.strictEqual(
115+
zlib.inflateSync(Buffer.concat(chunks)).toString(),
116+
'world',
117+
);
118+
});
119+
120+
test('DeflateRaw reset after write without emitted output still works',
121+
async () => {
122+
const stream = zlib.createDeflateRaw();
123+
const chunks = [];
124+
stream.on('data', (chunk) => chunks.push(chunk));
125+
126+
await writeHello(stream);
127+
assert.strictEqual(Buffer.concat(chunks).length, 0);
128+
129+
stream.reset();
130+
stream.end(Buffer.from('world'));
131+
await finished(stream);
132+
133+
assert.strictEqual(
134+
zlib.inflateRawSync(Buffer.concat(chunks)).toString(),
135+
'world',
136+
);
137+
});
138+
139+
test('DeflateRaw flush followed by reset still concatenates', async () => {
140+
const stream = zlib.createDeflateRaw();
141+
const chunks = [];
142+
stream.on('data', (chunk) => chunks.push(chunk));
143+
144+
stream.write(Buffer.from('hello'));
145+
await new Promise((resolve) => stream.flush(resolve));
146+
stream.reset();
147+
stream.end(Buffer.from('world'));
148+
await finished(stream);
149+
150+
assert.strictEqual(
151+
zlib.inflateRawSync(Buffer.concat(chunks)).toString(),
152+
'helloworld',
153+
);
154+
});

0 commit comments

Comments
 (0)