Skip to content

Commit e3e9c1c

Browse files
authored
Merge pull request #6294 from cloudflare/yagiz/fix-zlib-memory-lmit-exceeded
2 parents 88f9705 + 3a8b787 commit e3e9c1c

File tree

3 files changed

+97
-0
lines changed

3 files changed

+97
-0
lines changed

src/workerd/api/node/tests/zlib-nodejs-test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2857,3 +2857,57 @@ export const zlibParamsAfterWriteNoStalePointers = {
28572857
);
28582858
},
28592859
};
2860+
2861+
// Regression test for https://github.com/cloudflare/workerd/issues/6286
2862+
// inflateRawSync throws "Memory limit exceeded" when maxOutputLength is set
2863+
// to exactly the decompressed size. This is an off-by-one error in the
2864+
// GrowableBuffer: after zlib fills the buffer completely (avail_out == 0),
2865+
// the processing loop tries to add another chunk which exceeds maxCapacity.
2866+
export const maxOutputLengthExactSize = {
2867+
test() {
2868+
// Create a known payload and compress it with deflateRaw
2869+
const original = Buffer.from('a]b]c]d]e]f]g]h]i]j]k]l]m]n]o]p]'.repeat(32));
2870+
const compressed = zlib.deflateRawSync(original);
2871+
const exactSize = original.length;
2872+
2873+
// This should succeed — maxOutputLength is exactly the output size.
2874+
// Before the fix, this throws RangeError: "Memory limit exceeded"
2875+
const decompressed = zlib.inflateRawSync(compressed, {
2876+
maxOutputLength: exactSize,
2877+
});
2878+
assert.deepStrictEqual(decompressed, original);
2879+
2880+
// Sanity check: maxOutputLength + 1 also works
2881+
const decompressed2 = zlib.inflateRawSync(compressed, {
2882+
maxOutputLength: exactSize + 1,
2883+
});
2884+
assert.deepStrictEqual(decompressed2, original);
2885+
2886+
// Same bug affects inflateSync
2887+
const compressedZlib = zlib.deflateSync(original);
2888+
const decompressed3 = zlib.inflateSync(compressedZlib, {
2889+
maxOutputLength: exactSize,
2890+
});
2891+
assert.deepStrictEqual(decompressed3, original);
2892+
2893+
// And gunzipSync
2894+
const compressedGzip = zlib.gzipSync(original);
2895+
const decompressed4 = zlib.gunzipSync(compressedGzip, {
2896+
maxOutputLength: exactSize,
2897+
});
2898+
assert.deepStrictEqual(decompressed4, original);
2899+
2900+
// And brotliDecompressSync
2901+
const compressedBrotli = zlib.brotliCompressSync(original);
2902+
const decompressed5 = zlib.brotliDecompressSync(compressedBrotli, {
2903+
maxOutputLength: exactSize,
2904+
});
2905+
assert.deepStrictEqual(decompressed5, original);
2906+
2907+
// Verify that a maxOutputLength that's genuinely too small still throws
2908+
assert.throws(
2909+
() => zlib.inflateRawSync(compressed, { maxOutputLength: exactSize - 1 }),
2910+
RangeError
2911+
);
2912+
},
2913+
};

src/workerd/api/node/zlib-util.c++

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ class GrowableBuffer final {
9393
}
9494
}
9595

96+
bool atMaxCapacity() const {
97+
return size() >= maxCapacity;
98+
}
99+
96100
private:
97101
kj::ArrayBuilder<kj::byte> builder;
98102
size_t chunkSize;
@@ -670,6 +674,8 @@ void BrotliEncoderContext::work() {
670674
lastResult = BrotliEncoderCompressStream(
671675
state.get(), flush, &availIn, &internalNext, &availOut, &nextOut, nullptr);
672676
nextIn += internalNext - nextIn;
677+
678+
streamEnd = lastResult && BrotliEncoderIsFinished(state.get());
673679
}
674680

675681
kj::Maybe<CompressionError> BrotliEncoderContext::initialize(
@@ -709,6 +715,10 @@ kj::Maybe<CompressionError> BrotliEncoderContext::getError() const {
709715
return kj::none;
710716
}
711717

718+
bool BrotliEncoderContext::isStreamEnd() const {
719+
return streamEnd;
720+
}
721+
712722
BrotliDecoderContext::BrotliDecoderContext(ZlibMode _mode): BrotliContext(_mode) {
713723
auto instance = BrotliDecoderCreateInstance(alloc_brotli, free_brotli, alloc_opaque_brotli);
714724
state = kj::disposeWith<BrotliDecoderDestroyInstance>(instance);
@@ -770,6 +780,10 @@ kj::Maybe<CompressionError> BrotliDecoderContext::getError() const {
770780
return kj::none;
771781
}
772782

783+
bool BrotliDecoderContext::isStreamEnd() const {
784+
return lastResult == BROTLI_DECODER_RESULT_SUCCESS;
785+
}
786+
773787
// =======================================================================================
774788
// Zstd Implementation
775789

@@ -892,6 +906,11 @@ kj::Maybe<CompressionError> ZstdEncoderContext::getError() const {
892906
return kj::none;
893907
}
894908

909+
bool ZstdEncoderContext::isStreamEnd() const {
910+
// ZSTD_compressStream2 returns 0 when flush_ == ZSTD_e_end and the frame is fully flushed.
911+
return !ZSTD_isError(lastResult) && lastResult == 0;
912+
}
913+
895914
ZstdDecoderContext::ZstdDecoderContext(ZlibMode _mode)
896915
: ZstdContext(_mode),
897916
dctx_(kj::disposeWith<zstdFreeDCtx>(ZSTD_createDCtx())) {}
@@ -959,6 +978,11 @@ kj::Maybe<CompressionError> ZstdDecoderContext::getError() const {
959978
return kj::none;
960979
}
961980

981+
bool ZstdDecoderContext::isStreamEnd() const {
982+
// ZSTD_decompressStream returns 0 when a frame is completely decoded and fully flushed.
983+
return !ZSTD_isError(lastResult) && lastResult == 0;
984+
}
985+
962986
template <typename CompressionContext>
963987
jsg::Ref<ZlibUtil::ZstdCompressionStream<CompressionContext>> ZlibUtil::ZstdCompressionStream<
964988
CompressionContext>::constructor(jsg::Lock& js, ZlibModeValue mode) {
@@ -1052,6 +1076,14 @@ static kj::Array<kj::byte> syncProcessBuffer(Context& ctx, GrowableBuffer& resul
10521076
}
10531077

10541078
result.adjustUnused(ctx.getAvailOut());
1079+
1080+
if (ctx.getAvailOut() == 0 && result.atMaxCapacity()) {
1081+
// The output buffer was completely filled and has reached maxOutputLength.
1082+
// If the stream is done, the output just happened to fit exactly — return it.
1083+
// Otherwise the decompressed data exceeds maxOutputLength.
1084+
JSG_REQUIRE(ctx.isStreamEnd(), RangeError, "Memory limit exceeded");
1085+
break;
1086+
}
10551087
} while (ctx.getAvailOut() == 0);
10561088

10571089
return result.releaseAsArray();

src/workerd/api/node/zlib-util.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ class ZlibContext final {
149149
// Ref: https://github.com/nodejs/node/blob/9edf4a0856681a7665bd9dcf2ca7cac252784b98/src/node_zlib.cc#L760
150150
void work();
151151

152+
// Returns true when the zlib stream has reached Z_STREAM_END, indicating
153+
// that all compressed data has been fully processed.
154+
bool isStreamEnd() const {
155+
return err == Z_STREAM_END;
156+
}
157+
152158
uint getAvailIn() const {
153159
return stream.avail_in;
154160
};
@@ -284,9 +290,11 @@ class BrotliEncoderContext final: public BrotliContext {
284290
kj::Maybe<CompressionError> resetStream();
285291
kj::Maybe<CompressionError> setParams(int key, uint32_t value);
286292
kj::Maybe<CompressionError> getError() const;
293+
bool isStreamEnd() const;
287294

288295
private:
289296
bool lastResult = false;
297+
bool streamEnd = false;
290298
kj::Own<BrotliEncoderStateStruct> state;
291299
};
292300

@@ -304,6 +312,7 @@ class BrotliDecoderContext final: public BrotliContext {
304312
kj::Maybe<CompressionError> resetStream();
305313
kj::Maybe<CompressionError> setParams(int key, uint32_t value);
306314
kj::Maybe<CompressionError> getError() const;
315+
bool isStreamEnd() const;
307316

308317
private:
309318
BrotliDecoderResult lastResult = BROTLI_DECODER_RESULT_SUCCESS;
@@ -360,6 +369,7 @@ class ZstdEncoderContext final: public ZstdContext {
360369
kj::Maybe<CompressionError> resetStream();
361370
kj::Maybe<CompressionError> setParams(int key, int value);
362371
kj::Maybe<CompressionError> getError() const;
372+
bool isStreamEnd() const;
363373

364374
private:
365375
size_t lastResult = 0;
@@ -378,6 +388,7 @@ class ZstdDecoderContext final: public ZstdContext {
378388
kj::Maybe<CompressionError> resetStream();
379389
kj::Maybe<CompressionError> setParams(int key, int value);
380390
kj::Maybe<CompressionError> getError() const;
391+
bool isStreamEnd() const;
381392

382393
private:
383394
size_t lastResult = 0;

0 commit comments

Comments
 (0)