Skip to content

Commit c2cfcf8

Browse files
ArchkonRafaelGSS
authored andcommitted
zlib: reject truncated zstd input
Treat an unfinished Zstd frame as an unexpected end of file when the stream is finalized with ZSTD_e_end. Avoid reporting an error while the output buffer still needs to be drained or when an empty final write follows a completed frame. Preserve partial decompression when ZSTD_e_flush is used. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: #64593 Fixes: #64592 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Ryuhei Shima <shimaryuhei@gmail.com>
1 parent 0a89bef commit c2cfcf8

2 files changed

Lines changed: 83 additions & 5 deletions

File tree

src/node_zlib.cc

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ class ZstdDecompressContext final : public ZstdContext {
358358
// Streaming-related, should be available for all compression libraries:
359359
void Close();
360360
void DoThreadPoolWork();
361+
CompressionError GetErrorInfo() const;
361362
CompressionError ResetStream();
362363

363364
// Zstd specific:
@@ -375,6 +376,7 @@ class ZstdDecompressContext final : public ZstdContext {
375376

376377
private:
377378
DeleteFnPtr<ZSTD_DCtx, ZstdDecompressContext::FreeZstd> dctx_;
379+
bool frame_complete_ = false;
378380
};
379381

380382
class CompressionStreamMemoryOwner {
@@ -1721,6 +1723,8 @@ void ZstdDecompressContext::Close() {
17211723

17221724
CompressionError ZstdDecompressContext::Init(uint64_t pledged_src_size,
17231725
std::string_view dictionary) {
1726+
frame_complete_ = false;
1727+
17241728
#ifdef NODE_BUNDLED_ZSTD
17251729
ZSTD_customMem custom_mem = {
17261730
CompressionStreamMemoryOwner::AllocForBrotli,
@@ -1756,12 +1760,37 @@ CompressionError ZstdDecompressContext::ResetStream() {
17561760
}
17571761

17581762
void ZstdDecompressContext::DoThreadPoolWork() {
1763+
// The JavaScript processing loop retries with an empty input buffer when the
1764+
// previous call filled the output buffer. Avoid interpreting that retry as
1765+
// the beginning of a new, incomplete frame.
1766+
if (frame_complete_ && input_.size == 0) {
1767+
return;
1768+
}
1769+
17591770
size_t const ret = ZSTD_decompressStream(dctx_.get(), &output_, &input_);
17601771
if (ZSTD_isError(ret)) {
1772+
frame_complete_ = false;
17611773
error_ = ZSTD_getErrorCode(ret);
17621774
error_code_string_ = ZstdStrerror(error_);
17631775
error_string_ = ZSTD_getErrorString(error_);
1776+
} else {
1777+
frame_complete_ = ret == 0;
1778+
}
1779+
}
1780+
1781+
CompressionError ZstdDecompressContext::GetErrorInfo() const {
1782+
CompressionError error = ZstdContext::GetErrorInfo();
1783+
if (error.IsError()) {
1784+
return error;
17641785
}
1786+
1787+
if (flush_ == ZSTD_e_end && !frame_complete_ && input_.pos == input_.size &&
1788+
output_.pos < output_.size) {
1789+
return CompressionError(
1790+
"unexpected end of file", "Z_BUF_ERROR", Z_BUF_ERROR);
1791+
}
1792+
1793+
return {};
17651794
}
17661795

17671796
template <typename Stream>

test/parallel/test-zlib-truncated.js

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ const errMessage = /unexpected end of file/;
2222
{ comp: 'gzip', decomp: 'unzip', decompSync: 'unzipSync' },
2323
{ comp: 'deflate', decomp: 'inflate', decompSync: 'inflateSync' },
2424
{ comp: 'deflateRaw', decomp: 'inflateRaw', decompSync: 'inflateRawSync' },
25+
{
26+
comp: 'zstdCompress',
27+
decomp: 'zstdDecompress',
28+
decompSync: 'zstdDecompressSync',
29+
partialFlush: zlib.constants.ZSTD_e_flush,
30+
},
2531
].forEach(function(methods) {
2632
zlib[methods.comp](inputString, common.mustSucceed((compressed) => {
2733
const truncated = compressed.slice(0, compressed.length / 2);
@@ -46,16 +52,59 @@ const errMessage = /unexpected end of file/;
4652
assert.match(err.message, errMessage);
4753
}));
4854

49-
const syncFlushOpt = { finishFlush: zlib.constants.Z_SYNC_FLUSH };
55+
const partialFlushOpt = {
56+
finishFlush: methods.partialFlush ?? zlib.constants.Z_SYNC_FLUSH,
57+
};
5058

51-
// Sync truncated input test, finishFlush = Z_SYNC_FLUSH
52-
const result = toUTF8(zlib[methods.decompSync](truncated, syncFlushOpt));
59+
// Sync truncated input test with a non-finalizing finish flush.
60+
const result = toUTF8(zlib[methods.decompSync](truncated, partialFlushOpt));
5361
assert.strictEqual(result, inputString.slice(0, result.length));
5462

55-
// Async truncated input test, finishFlush = Z_SYNC_FLUSH
56-
zlib[methods.decomp](truncated, syncFlushOpt, common.mustSucceed((decompressed) => {
63+
// Async truncated input test with a non-finalizing finish flush.
64+
zlib[methods.decomp](truncated, partialFlushOpt, common.mustSucceed((decompressed) => {
5765
const result = toUTF8(decompressed);
5866
assert.strictEqual(result, inputString.slice(0, result.length));
5967
}));
6068
}));
6169
});
70+
71+
// A non-zero return from ZSTD_decompressStream() can also mean that the
72+
// output buffer is full. Make sure that is drained before treating the return
73+
// value as truncated input.
74+
{
75+
const input = Buffer.alloc(zlib.constants.Z_DEFAULT_CHUNK * 2, 0x61);
76+
const compressed = zlib.zstdCompressSync(input);
77+
const decompressed = zlib.zstdDecompressSync(compressed, {
78+
chunkSize: zlib.constants.Z_MIN_CHUNK,
79+
});
80+
assert.deepStrictEqual(decompressed, input);
81+
}
82+
83+
// Ending a stream after a previous write completed a frame must not be
84+
// mistaken for an empty, truncated frame.
85+
{
86+
const input = Buffer.from(inputString);
87+
const compressed = zlib.zstdCompressSync(input);
88+
const decompressor = zlib.createZstdDecompress();
89+
const output = [];
90+
91+
decompressor.on('data', (chunk) => output.push(chunk));
92+
decompressor.on('end', common.mustCall(() => {
93+
assert.deepStrictEqual(Buffer.concat(output), input);
94+
}));
95+
decompressor.write(compressed, common.mustCall(() => decompressor.end()));
96+
}
97+
98+
// Conversely, ending after a previous write supplied only part of a frame
99+
// must report that the frame is incomplete.
100+
{
101+
const compressed = zlib.zstdCompressSync(inputString);
102+
const truncated = compressed.subarray(0, compressed.length / 2);
103+
const decompressor = zlib.createZstdDecompress();
104+
105+
decompressor.on('error', common.mustCall((error) => {
106+
assert.match(error.message, errMessage);
107+
}));
108+
decompressor.write(truncated, common.mustCall(() => decompressor.end()));
109+
decompressor.resume();
110+
}

0 commit comments

Comments
 (0)