From f07934579516314b7b732b8e6f5c17bd4434cccd Mon Sep 17 00:00:00 2001 From: Ic3b3rg Date: Thu, 4 Jun 2026 16:08:23 +0200 Subject: [PATCH 1/2] zlib: validate flush kind for brotli streams BrotliCompress/Decompress.flush(kind) forwarded any value to the native layer, causing a 100% CPU hang for kinds outside the brotli operation range (e.g. Z_FINISH). Validate against [0, 3] and throw ERR_OUT_OF_RANGE on invalid input. Fixes: https://github.com/nodejs/node/issues/63701 Signed-off-by: Ic3b3rg --- lib/zlib.js | 13 ++++ .../test-zlib-brotli-flush-invalid-kind.js | 69 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 test/parallel/test-zlib-brotli-flush-invalid-kind.js diff --git a/lib/zlib.js b/lib/zlib.js index d4f2446a5976cb..0a4fc1e15dddd8 100644 --- a/lib/zlib.js +++ b/lib/zlib.js @@ -268,6 +268,7 @@ function ZlibBase(opts, mode, handle, { flush, finishFlush, fullFlush }) { this._defaultFlushFlag = flush; this._finishFlushFlag = finishFlush; this._defaultFullFlushFlag = fullFlush; + this._flushBoundIdx = flushBoundIdx; this._info = opts?.info; this._maxOutputLength = maxOutputLength; @@ -349,6 +350,18 @@ ZlibBase.prototype.flush = function(kind, callback) { kind = this._defaultFullFlushFlag; } + // Reject kinds outside the brotli operation range. Otherwise the fake-chunk + // path forwards an unsupported flush flag to the native encoder/decoder, + // which spins at 100% CPU without making progress (see #63701). + if (this._flushBoundIdx === FLUSH_BOUND_IDX_BROTLI) { + const min = FLUSH_BOUND[FLUSH_BOUND_IDX_BROTLI][0]; + const max = FLUSH_BOUND[FLUSH_BOUND_IDX_BROTLI][1]; + if (typeof kind !== 'number' || NumberIsNaN(kind) || + kind < min || kind > max) { + throw new ERR_OUT_OF_RANGE('kind', `>= ${min} and <= ${max}`, kind); + } + } + if (this.writableFinished) { if (callback) process.nextTick(callback); diff --git a/test/parallel/test-zlib-brotli-flush-invalid-kind.js b/test/parallel/test-zlib-brotli-flush-invalid-kind.js new file mode 100644 index 00000000000000..40765cccbe7a21 --- /dev/null +++ b/test/parallel/test-zlib-brotli-flush-invalid-kind.js @@ -0,0 +1,69 @@ +'use strict'; +// Regression test for https://github.com/nodejs/node/issues/63701 +// BrotliCompress/BrotliDecompress.flush(kind) used to spin at 100% CPU when +// kind was outside the brotli operation range (0..3) — e.g. Z_FINISH (4) or +// Z_BLOCK (5). It must now throw ERR_OUT_OF_RANGE before reaching the native +// layer. + +require('../common'); +const assert = require('assert'); +const zlib = require('zlib'); + +const { + BROTLI_OPERATION_PROCESS, + BROTLI_OPERATION_FLUSH, + BROTLI_OPERATION_FINISH, + BROTLI_OPERATION_EMIT_METADATA, + Z_BLOCK, + Z_FINISH, +} = zlib.constants; + +// Brotli operations 0..3 are valid and must not throw synchronously from +// flush(). Some operations (e.g. FINISH on a decoder with no input) emit a +// Z_BUF_ERROR asynchronously — that's expected and unrelated to the input +// validation under test, so we attach a noop error handler. +for (const validKind of [ + BROTLI_OPERATION_PROCESS, + BROTLI_OPERATION_FLUSH, + BROTLI_OPERATION_FINISH, + BROTLI_OPERATION_EMIT_METADATA, +]) { + const c = zlib.createBrotliCompress(); + c.on('error', () => {}); + c.flush(validKind); + + const d = zlib.createBrotliDecompress(); + d.on('error', () => {}); + d.flush(validKind); +} + +// Values outside [0, 3] must throw ERR_OUT_OF_RANGE for both compress and +// decompress streams. Z_FINISH (4) and Z_BLOCK (5) previously hung at 100% CPU. +const outOfRange = [-1, Z_FINISH, Z_BLOCK, 6, 7, 100]; + +for (const factory of [zlib.createBrotliCompress, zlib.createBrotliDecompress]) { + for (const kind of outOfRange) { + assert.throws( + () => factory().flush(kind), + { code: 'ERR_OUT_OF_RANGE', name: 'RangeError' }, + ); + } +} + +// Non-number kinds must also throw, instead of silently triggering the +// fake-chunk path with an undefined buffer. +for (const factory of [zlib.createBrotliCompress, zlib.createBrotliDecompress]) { + for (const kind of ['foobar', null, {}, NaN]) { + assert.throws( + () => factory().flush(kind), + { code: 'ERR_OUT_OF_RANGE' }, + ); + } +} + +// flush() with no arguments (or with only a callback) must still work — +// it uses the stream's _defaultFullFlushFlag, which is a valid brotli op. +zlib.createBrotliCompress().flush(); +zlib.createBrotliCompress().flush(() => {}); +zlib.createBrotliDecompress().flush(); +zlib.createBrotliDecompress().flush(() => {}); From eaa6c3a9e4e5a5b0c4f5e6d4852444a1cb566bad Mon Sep 17 00:00:00 2001 From: Ic3b3rg Date: Fri, 5 Jun 2026 09:50:04 +0200 Subject: [PATCH 2/2] zlib: validate flush king for all streams Signed-off-by: Ic3b3rg --- lib/zlib.js | 15 +-- .../test-zlib-brotli-flush-invalid-kind.js | 113 +++++++++++------- 2 files changed, 72 insertions(+), 56 deletions(-) diff --git a/lib/zlib.js b/lib/zlib.js index 0a4fc1e15dddd8..55d7cf997e56a6 100644 --- a/lib/zlib.js +++ b/lib/zlib.js @@ -350,17 +350,10 @@ ZlibBase.prototype.flush = function(kind, callback) { kind = this._defaultFullFlushFlag; } - // Reject kinds outside the brotli operation range. Otherwise the fake-chunk - // path forwards an unsupported flush flag to the native encoder/decoder, - // which spins at 100% CPU without making progress (see #63701). - if (this._flushBoundIdx === FLUSH_BOUND_IDX_BROTLI) { - const min = FLUSH_BOUND[FLUSH_BOUND_IDX_BROTLI][0]; - const max = FLUSH_BOUND[FLUSH_BOUND_IDX_BROTLI][1]; - if (typeof kind !== 'number' || NumberIsNaN(kind) || - kind < min || kind > max) { - throw new ERR_OUT_OF_RANGE('kind', `>= ${min} and <= ${max}`, kind); - } - } + kind = checkRangesOrGetDefault( + kind, 'kind', + FLUSH_BOUND[this._flushBoundIdx][0], FLUSH_BOUND[this._flushBoundIdx][1], + this._defaultFullFlushFlag); if (this.writableFinished) { if (callback) diff --git a/test/parallel/test-zlib-brotli-flush-invalid-kind.js b/test/parallel/test-zlib-brotli-flush-invalid-kind.js index 40765cccbe7a21..af8a2c67d1d9a4 100644 --- a/test/parallel/test-zlib-brotli-flush-invalid-kind.js +++ b/test/parallel/test-zlib-brotli-flush-invalid-kind.js @@ -1,9 +1,7 @@ 'use strict'; -// Regression test for https://github.com/nodejs/node/issues/63701 -// BrotliCompress/BrotliDecompress.flush(kind) used to spin at 100% CPU when -// kind was outside the brotli operation range (0..3) — e.g. Z_FINISH (4) or -// Z_BLOCK (5). It must now throw ERR_OUT_OF_RANGE before reaching the native -// layer. +// Regression test for https://github.com/nodejs/node/issues/63701. +// Invalid Brotli flush kinds used to spin in native code. flush(kind) should +// reject invalid kinds before writing the fake flush chunk. require('../common'); const assert = require('assert'); @@ -14,56 +12,81 @@ const { BROTLI_OPERATION_FLUSH, BROTLI_OPERATION_FINISH, BROTLI_OPERATION_EMIT_METADATA, + Z_NO_FLUSH, Z_BLOCK, Z_FINISH, + ZSTD_e_continue, + ZSTD_e_flush, + ZSTD_e_end, } = zlib.constants; -// Brotli operations 0..3 are valid and must not throw synchronously from -// flush(). Some operations (e.g. FINISH on a decoder with no input) emit a -// Z_BUF_ERROR asynchronously — that's expected and unrelated to the input -// validation under test, so we attach a noop error handler. -for (const validKind of [ - BROTLI_OPERATION_PROCESS, - BROTLI_OPERATION_FLUSH, - BROTLI_OPERATION_FINISH, - BROTLI_OPERATION_EMIT_METADATA, -]) { - const c = zlib.createBrotliCompress(); - c.on('error', () => {}); - c.flush(validKind); +const noop = () => {}; - const d = zlib.createBrotliDecompress(); - d.on('error', () => {}); - d.flush(validKind); -} +const flushKindTestCases = [ + { + factories: [zlib.createGzip], + validKinds: [Z_NO_FLUSH, Z_FINISH, Z_BLOCK], + invalidKinds: [-1, 6, 100], + }, + { + factories: [zlib.createBrotliCompress, zlib.createBrotliDecompress], + validKinds: [ + BROTLI_OPERATION_PROCESS, + BROTLI_OPERATION_FLUSH, + BROTLI_OPERATION_FINISH, + BROTLI_OPERATION_EMIT_METADATA, + ], + invalidKinds: [-1, Z_FINISH, Z_BLOCK, 6, 100], + }, + { + factories: [zlib.createZstdCompress, zlib.createZstdDecompress], + validKinds: [ZSTD_e_continue, ZSTD_e_flush, ZSTD_e_end], + invalidKinds: [-1, 3, Z_FINISH, Z_BLOCK, 100], + }, +]; -// Values outside [0, 3] must throw ERR_OUT_OF_RANGE for both compress and -// decompress streams. Z_FINISH (4) and Z_BLOCK (5) previously hung at 100% CPU. -const outOfRange = [-1, Z_FINISH, Z_BLOCK, 6, 7, 100]; +for (const { factories, validKinds } of flushKindTestCases) { + for (const factory of factories) { + for (const kind of validKinds) { + const stream = factory(); + stream.on('error', noop); + stream.flush(kind); + } + } +} -for (const factory of [zlib.createBrotliCompress, zlib.createBrotliDecompress]) { - for (const kind of outOfRange) { - assert.throws( - () => factory().flush(kind), - { code: 'ERR_OUT_OF_RANGE', name: 'RangeError' }, - ); +for (const { factories, invalidKinds } of flushKindTestCases) { + for (const factory of factories) { + for (const kind of invalidKinds) { + assert.throws( + () => factory().flush(kind), + { code: 'ERR_OUT_OF_RANGE', name: 'RangeError' }, + ); + } } } -// Non-number kinds must also throw, instead of silently triggering the -// fake-chunk path with an undefined buffer. -for (const factory of [zlib.createBrotliCompress, zlib.createBrotliDecompress]) { - for (const kind of ['foobar', null, {}, NaN]) { - assert.throws( - () => factory().flush(kind), - { code: 'ERR_OUT_OF_RANGE' }, - ); +for (const { factories } of flushKindTestCases) { + for (const factory of factories) { + for (const kind of ['foobar', null, {}]) { + assert.throws( + () => factory().flush(kind), + { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError' }, + ); + } } } -// flush() with no arguments (or with only a callback) must still work — -// it uses the stream's _defaultFullFlushFlag, which is a valid brotli op. -zlib.createBrotliCompress().flush(); -zlib.createBrotliCompress().flush(() => {}); -zlib.createBrotliDecompress().flush(); -zlib.createBrotliDecompress().flush(() => {}); +for (const { factories } of flushKindTestCases) { + for (const factory of factories) { + for (const kind of [undefined, NaN]) { + const stream = factory(); + stream.on('error', noop); + stream.flush(kind); + } + + const stream = factory(); + stream.on('error', noop); + stream.flush(noop); + } +}