From a6abe9b8c4de6eb0c467a04436219115068ff773 Mon Sep 17 00:00:00 2001 From: Enoch Groot Date: Mon, 21 Sep 2026 22:49:42 +0000 Subject: [PATCH 1/3] fix: close review issues #6-#13 on 3.4.0 Rebase the OPEN review follow-ups onto live upstream master (c0f78a1, 3.4.0) so Stream drain/backpressure from #43 is kept. Pin nan 2.28.0 and c8 12.0.0; CI uses npm ci on Node 22/24 with full-SHA GitHub Actions. engines.node is >=22. Cap Stream receive before allocate and drop buf on close/end/error. Re-emit underlying socket errors when the Stream is not already dead so a socket 'error' is not swallowed by the listener. Apply kMaxContainer to pack array/map walks. Cap CLI stdin concat at MAX_STDIN_BYTES. Document a risk-based bump window in SECURITY.md. --- .github/workflows/ci.yml | 25 +++++------ README.md | 30 +++++++++----- SECURITY.md | 39 +++++++++++++++++- bin/json2msgpack | 11 ++++- bin/msgpack2json | 11 ++++- index.d.ts | 25 +++++++++-- lib/msgpack.js | 48 ++++++++++++++++++++-- package-lock.json | 6 +-- package.json | 6 +-- src/msgpack.cc | 10 +++++ test/cli.test.js | 14 +++++++ test/msgpack.test.js | 89 ++++++++++++++++++++++++++++++++++++++-- test/security.test.js | 14 +++++++ 13 files changed, 282 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6cc211..d86f6cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,41 +11,36 @@ jobs: fail-fast: false matrix: # windows-2022 ships VS 2022. windows-latest currently has VS 2026 - # (18.x), which node-gyp 10/11 bundled with Node 18/20/22 reports as + # (18.x), which node-gyp 10/11 bundled with Node 22/24 reports as # unknown version "undefined" and then fails the native rebuild. os: [ubuntu-latest, macos-latest, windows-2022] - node: [18, 20, 22] + node: [22, 24] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node }} - name: Install and test # bash so `&&` is a hard stop on Windows too (PowerShell would still # run npm test after a failed rebuild). shell: bash - run: npm install && npm test + run: npm ci && npm test coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 20 - - uses: actions/setup-python@v5 + node-version: 22 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.x' - name: Install gcovr run: | gcovr --version || pip install --upgrade gcovr - name: Install dependencies - run: | - if [ -f package-lock.json ]; then - npm ci || npm install - else - npm install - fi + run: npm ci # Fails the job when JS or native coverage drops below 95%. - name: Coverage run: npm run coverage diff --git a/README.md b/README.md index ffdc5b5..c0f1e0e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ and de-serializes JavaScript values with [MessagePack](https://msgpack.org). Packed output is a `Buffer` and is typically much smaller than JSON. -Version 3.3 requires **Node.js 18+**, vendors **msgpack-c c-7.0.2**, unpacks +Version 3.4 requires **Node.js 22+**, vendors **msgpack-c c-7.0.2**, unpacks 64-bit integers outside `Number.MAX_SAFE_INTEGER` as `bigint`, accepts optional pack type/family hints, can unpack maps and arrays lazily (`unpack(buf, { lazy: true })`), and applies write backpressure on @@ -27,7 +27,9 @@ and returns a JavaScript value, or `null` if the buffer is a truncated (incomplete) MessagePack object. Oversized array/map/string bombs throw. A streaming helper wraps a readable socket and emits `msg`, plus `error` when -a packet cannot be unpacked (the offending buffer is dropped). `send()` packs +a packet cannot be unpacked, the receive buffer would exceed +`MAX_STREAM_BYTES` (the offending buffer is dropped, and the socket is +destroyed when possible), or the underlying stream errors. `send()` packs and writes; it returns the boolean from the underlying `write()`, or `false` if the message was queued because a previous write returned `false` and `drain` has not fired yet. `drain` is re-emitted from the underlying @@ -147,26 +149,31 @@ Default packing is unchanged when no recognized options object is passed. ### Limits -* array/map length ≤ 1,000,000 +* array/map length ≤ 1,000,000 on both pack and unpack * str/bin/ext length ≤ 32 MiB * nesting depth ≤ 512 on both pack and unpack +* Stream receive buffer ≤ `MAX_STREAM_BYTES` (32 MiB + 16 bytes of framing) +* CLI stdin ≤ `MAX_STDIN_BYTES` (32 MiB) before concat/parse The payload `dd ff 00 00 00` throws `msgpack unpack limit exceeded`. Packing a -value nested deeper than 512 throws `Cowardly refusing to pack object nested -more than 512 levels deep` instead of overflowing the C stack. +sparse array or map whose length exceeds 1,000,000 throws +`msgpack pack limit exceeded`. Packing a value nested deeper than 512 throws +`Cowardly refusing to pack object nested more than 512 levels deep` instead of +overflowing the C stack. Incomplete Stream frames that would grow past +`MAX_STREAM_BYTES` throw `msgpack stream limit exceeded` before allocate. ### Building, installation, testing ``` -npm install +npm ci npm test npm run coverage ``` -Needs a C/C++ toolchain and Python (node-gyp). GitHub Actions runs Node 18/20/22 -on Ubuntu and macOS. `npm run coverage` instruments JavaScript with c8 and the -native addon with gcov, and fails under 95%. Gates and remaining uncovered -lines are documented in [`COVERAGE.md`](COVERAGE.md). +Needs a C/C++ toolchain and Python (node-gyp). GitHub Actions runs Node 22/24 +on Ubuntu, macOS, and windows-2022. `npm run coverage` instruments JavaScript +with c8 and the native addon with gcov, and fails under 95%. Gates and remaining +uncovered lines are documented in [`COVERAGE.md`](COVERAGE.md). ### Command Line Utilities @@ -202,7 +209,8 @@ echo '{"hello":"world"}' | bin/json2msgpack | bin/msgpack2json ``` `msgpack2json` prints one JSON value per line and consumes every complete -message in its input. Both exit non-zero on invalid or truncated input. +message in its input. Both exit non-zero on invalid or truncated input, and +both refuse stdin larger than `MAX_STDIN_BYTES` (32 MiB). ### Benchmarks diff --git a/SECURITY.md b/SECURITY.md index 7d3a112..ba18399 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,4 +1,4 @@ -# Security notes (node-msgpack 2.0.0) +# Security notes (node-msgpack 3.4.0) This package vendors [msgpack-c](https://github.com/msgpack/msgpack-c) **c-7.0.2** (`e17beb371b59459a13b48e166a11e123bda5bf93`), the C library. @@ -50,6 +50,43 @@ on circular refs / unencodable values without freeing it. The sbuffer is now owned by an RAII guard that returns pooled buffers or `msgpack_sbuffer_free`s on every exit path, including C++ exceptions. +## Stream receive buffer + +`msgpack.Stream` concatenates incomplete frames into `self.buf`. Before any +allocate, a new chunk is rejected when `self.buf.length + chunk.length` would +exceed `MAX_STREAM_BYTES` (32 MiB plus 16 bytes of MessagePack framing). The +buffer is dropped, `'error'` is emitted (`msgpack stream limit exceeded`), +and the underlying stream is `destroy()`ed when that method exists. `close`, +`end`, and `error` on the underlying stream also drop `self.buf`. + +## CLI stdin + +`bin/json2msgpack` and `bin/msgpack2json` refuse stdin larger than +`MAX_STDIN_BYTES` (32 MiB) before `Buffer.concat` / `JSON.parse` / +`unpack`. They exit 1 with `stdin exceeds MAX_STDIN_BYTES`. + +## Pack container size + +`pack()` rejects arrays and maps whose own length exceeds 1,000,000 +(`kMaxContainer`), the same policy as unpack. Sparse `Array.length` above +that cap throws `msgpack pack limit exceeded` without walking the holes. + +## Dependency bump window + +The pins below are inventory, not a calendar SLA: + +- msgpack-c **c-7.0.2** (`e17beb371b59459a13b48e166a11e123bda5bf93`) +- NAN **2.28.0** (compile-in; exact in `package.json` / shrinkwrap) + +Risk-based window: + +- **Critical or high** native advisories in vendored msgpack-c or in + compile-in NAN: bump to a reviewed fix, or document why the pin stays, + within **14 days** of that fix being available. +- **Medium**: next minor of this package. +- **Low / no advisory**: no scheduled bump. The inventory pin is not a + commitment to track upstream on a calendar. + ## License First-party code in this repository is BSD-3-Clause (see `LICENSE`). The diff --git a/bin/json2msgpack b/bin/json2msgpack index 1aa2f6e..2659fae 100755 --- a/bin/json2msgpack +++ b/bin/json2msgpack @@ -5,9 +5,18 @@ const msgpack = require('../lib/msgpack'); +const MAX_STDIN_BYTES = msgpack.MAX_STDIN_BYTES; const chunks = []; +let n = 0; -process.stdin.on('data', (d) => chunks.push(d)); +process.stdin.on('data', (d) => { + n += d.length; + if (n > MAX_STDIN_BYTES) { + console.error('json2msgpack: stdin exceeds MAX_STDIN_BYTES (' + MAX_STDIN_BYTES + ')'); + process.exit(1); + } + chunks.push(d); +}); process.stdin.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); diff --git a/bin/msgpack2json b/bin/msgpack2json index 5344a1b..f8bb21c 100755 --- a/bin/msgpack2json +++ b/bin/msgpack2json @@ -5,9 +5,18 @@ const msgpack = require('../lib/msgpack'); +const MAX_STDIN_BYTES = msgpack.MAX_STDIN_BYTES; const chunks = []; +let n = 0; -process.stdin.on('data', (d) => chunks.push(d)); +process.stdin.on('data', (d) => { + n += d.length; + if (n > MAX_STDIN_BYTES) { + console.error('msgpack2json: stdin exceeds MAX_STDIN_BYTES (' + MAX_STDIN_BYTES + ')'); + process.exit(1); + } + chunks.push(d); +}); process.stdin.on('end', () => { let buf = Buffer.concat(chunks); diff --git a/index.d.ts b/index.d.ts index 8bee345..7b59c33 100644 --- a/index.d.ts +++ b/index.d.ts @@ -46,7 +46,8 @@ export interface PackOptions { * Serialize values to MessagePack. * * A single argument is packed as itself; two or more are packed as an array - * of that many elements. + * of that many elements. Arrays and maps whose length exceeds 1,000,000 + * throw `msgpack pack limit exceeded`. * * When the second argument own-enumerates only `type`, `family`, and/or * `interpret`, it is pack options rather than a second value. `type` forces @@ -96,14 +97,30 @@ export namespace unpack { let bytes_remaining: number; } +/** + * Cap on `Stream` receive concatenation (32 MiB plus 16 bytes of framing). + * A chunk that would take `buf.length + chunk.length` past this is rejected + * before allocate. + */ +export const MAX_STREAM_BYTES: number; + +/** + * Cap on CLI stdin accumulation in `json2msgpack` / `msgpack2json` (32 MiB). + * Overflow exits 1 before concat/parse. + */ +export const MAX_STDIN_BYTES: number; + /** * Frames MessagePack messages over a stream. * * Emits `'msg'` with each decoded value, `'drain'` when the underlying * writable is ready for more data and the send queue is empty, and - * `'error'` if a packet cannot be decoded (the buffered data is then - * dropped) or if queued sends are discarded because the underlying stream - * emitted `error`/`close`/`end`. + * `'error'` if a packet cannot be decoded, if the receive buffer would + * exceed `MAX_STREAM_BYTES` (the buffered data is then dropped; the + * underlying stream is destroyed when possible), if queued sends are + * discarded because the underlying stream emitted `error`/`close`/`end`, + * or if the underlying stream errors. `buf` is also dropped on the + * underlying stream's `close` / `end` / `error`. */ export class Stream extends EventEmitter { constructor(s: NodeJS.ReadWriteStream); diff --git a/lib/msgpack.js b/lib/msgpack.js index 8686024..dbfb72d 100644 --- a/lib/msgpack.js +++ b/lib/msgpack.js @@ -11,6 +11,12 @@ const mpBindings = require(__dirname + '/../build/Release/msgpackBinding'); const bpack = mpBindings.pack; const rawUnpack = mpBindings.unpack; +/* 32 MiB payload (same as unpack str/bin) plus 16 bytes of MessagePack + * framing so a max-legal bin32/str32 still fits in one Stream buffer. */ +const MAX_STREAM_BYTES = 32 * 1024 * 1024 + 16; +/* CLI stdin is capped at 32 MiB before concat/parse. */ +const MAX_STDIN_BYTES = 32 * 1024 * 1024; + /* No JS pre-pass: the binding already applies toJSON at every level and * packs Dates as ISO strings. Calling toJSON here instead turned a top-level * Buffer into Buffer.prototype.toJSON's {type,data} map rather than bin. */ @@ -32,6 +38,22 @@ function Stream(s) { const self = this; events.EventEmitter.call(self); self.buf = null; + let dead = false; + + function dropBuf() { + self.buf = null; + } + + function rejectLimit() { + if (dead) return; + dead = true; + dropBuf(); + const err = new Error('msgpack stream limit exceeded'); + self.emit('error', err); + if (typeof s.destroy === 'function') { + s.destroy(err); + } + } const queue = []; let waitingForDrain = false; @@ -113,10 +135,16 @@ function Stream(s) { s.addListener('end', abandonQueue); s.addListener('data', function (d) { + if (dead) return; + const have = self.buf ? self.buf.length : 0; + if (have + d.length > MAX_STREAM_BYTES) { + rejectLimit(); + return; + } if (self.buf) { - const b = buffer.Buffer.allocUnsafe(self.buf.length + d.length); - self.buf.copy(b, 0, 0, self.buf.length); - d.copy(b, self.buf.length, 0, d.length); + const b = buffer.Buffer.allocUnsafe(have + d.length); + self.buf.copy(b, 0, 0, have); + d.copy(b, have, 0, d.length); self.buf = b; } else { self.buf = d; @@ -152,6 +180,18 @@ function Stream(s) { self.emit('msg', msg); } }); + + s.addListener('close', dropBuf); + s.addListener('end', dropBuf); + /* Any 'error' listener counts as handling in Node, so this must re-emit + * on Stream. Skip when already dead: rejectLimit emits then destroy(err), + * which fires this listener again. */ + s.addListener('error', function (err) { + dropBuf(); + if (!dead) { + self.emit('error', err); + } + }); } util.inherits(Stream, events.EventEmitter); @@ -159,3 +199,5 @@ util.inherits(Stream, events.EventEmitter); exports.pack = pack; exports.unpack = unpack; exports.Stream = Stream; +exports.MAX_STREAM_BYTES = MAX_STREAM_BYTES; +exports.MAX_STDIN_BYTES = MAX_STDIN_BYTES; diff --git a/package-lock.json b/package-lock.json index 389cc91..54783fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,17 @@ "version": "3.3.0", "license": "BSD-3-Clause", "dependencies": { - "nan": "^2.23.1" + "nan": "2.28.0" }, "bin": { "json2msgpack": "bin/json2msgpack", "msgpack2json": "bin/msgpack2json" }, "devDependencies": { - "c8": "^12.0.0" + "c8": "12.0.0" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/@bcoe/v8-coverage": { diff --git a/package.json b/package.json index 7bc5cea..d2a9e10 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,10 @@ "lib": "lib" }, "engines": { - "node": ">=18" + "node": ">=22" }, "dependencies": { - "nan": "^2.23.1" + "nan": "2.28.0" }, "scripts": { "test": "node --test test/bigint.test.js test/cli.test.js test/coverage-native.test.js test/lazy.test.js test/msgpack.test.js test/pack-hints.test.js test/regression.test.js test/security.test.js test/worker.test.js", @@ -48,6 +48,6 @@ "gypfile": true, "license": "BSD-3-Clause", "devDependencies": { - "c8": "^12.0.0" + "c8": "12.0.0" } } diff --git a/src/msgpack.cc b/src/msgpack.cc index 92a158a..b8273e5 100644 --- a/src/msgpack.cc +++ b/src/msgpack.cc @@ -591,7 +591,13 @@ static void PackArray(msgpack_packer* pk, v8::Local arr, int depth) { throw MsgpackException(Error("Cowardly refusing to pack circular reference")); } Mark(arr); + /* Snapshot Length() once and freeze it for the walk so a growing getter + * cannot extend the loop. Same 1e6 policy as ScanOne. */ uint32_t len = arr->Length(); + if (len > kMaxContainer) { + Unmark(arr); + throw MsgpackException(Error("msgpack pack limit exceeded")); + } /* GCOVR_EXCL_BR_START: msgpack_sbuffer_write only fails on realloc * failure, which no JS-reachable input can force. */ if (msgpack_pack_array(pk, len)) { @@ -646,6 +652,10 @@ static void PackObject(msgpack_packer* pk, v8::Local obj, int depth) throw; } uint32_t len = names->Length(); + if (len > kMaxContainer) { + Unmark(obj); + throw MsgpackException(Error("msgpack pack limit exceeded")); + } /* GCOVR_EXCL_BR_START: allocation failure only, as in PackArray. */ if (msgpack_pack_map(pk, len)) { Unmark(obj); diff --git a/test/cli.test.js b/test/cli.test.js index 1e27297..96a5cd4 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -68,6 +68,13 @@ describe('json2msgpack error paths', () => { assert.equal(msgpack.unpack(r.stdout), want, text); } }); + + it('exits 1 before concat when stdin exceeds MAX_STDIN_BYTES', { timeout: 30000 }, () => { + const r = run('json2msgpack', Buffer.alloc(msgpack.MAX_STDIN_BYTES + 1)); + assert.equal(r.status, 1); + assert.match(r.stderr, /json2msgpack: stdin exceeds MAX_STDIN_BYTES/); + assert.equal(r.stdout.length, 0); + }); }); describe('msgpack2json error paths', () => { @@ -121,4 +128,11 @@ describe('msgpack2json error paths', () => { assert.equal(r.status, 0); assert.deepEqual(JSON.parse(r.stdout.toString('utf8')), value); }); + + it('exits 1 before concat when stdin exceeds MAX_STDIN_BYTES', { timeout: 30000 }, () => { + const r = run('msgpack2json', Buffer.alloc(msgpack.MAX_STDIN_BYTES + 1)); + assert.equal(r.status, 1); + assert.match(r.stderr, /msgpack2json: stdin exceeds MAX_STDIN_BYTES/); + assert.equal(r.stdout.length, 0); + }); }); diff --git a/test/msgpack.test.js b/test/msgpack.test.js index 8ea7576..387b782 100644 --- a/test/msgpack.test.js +++ b/test/msgpack.test.js @@ -324,7 +324,7 @@ describe('msgpack.Stream', () => { assert.equal(s.writes.length, 1); }); - it('does not emit error on close when the queue is empty', () => { + it('does not emit error on close or end when the queue is empty', () => { const s = mockWritable([true]); const ms = new msgpack.Stream(s); const errors = []; @@ -332,8 +332,13 @@ describe('msgpack.Stream', () => { ms.send('one'); s.emit('close'); s.emit('end'); - s.emit('error', new Error('socket')); assert.equal(errors.length, 0); + const sockErr = new Error('socket'); + s.emit('error', sockErr); + /* An 'error' listener on the socket counts as handled in Node, so the + * original socket error must still reach Stream. */ + assert.equal(errors.length, 1); + assert.strictEqual(errors[0], sockErr); }); it('drops the queue on underlying error and end', () => { @@ -343,8 +348,12 @@ describe('msgpack.Stream', () => { ms.on('error', (e) => errors.push(e)); ms.send('a'); ms.send('b'); - s.emit('error', new Error('socket')); - assert.equal(errors.length, 1); + const sockErr = new Error('socket'); + s.emit('error', sockErr); + /* Queue-drop error first, then the original socket error once. */ + assert.equal(errors.length, 2); + assert.match(errors[0].message, /unsent|backpressure/); + assert.strictEqual(errors[1], sockErr); const s2 = mockWritable([false]); const ms2 = new msgpack.Stream(s2); @@ -488,6 +497,78 @@ describe('msgpack.Stream', () => { assert.deepEqual(seen, ['one', 'two', 'three']); }); + function hugeChunk(n) { + return { + length: n, + copy() { + throw new Error('copy should not run'); + }, + }; + } + + it('rejects a receive concat that would exceed MAX_STREAM_BYTES before allocate', () => { + const s = new EventEmitter(); + let destroyed = 0; + /* Node's stream.destroy(err) emits 'error' on the socket. */ + s.destroy = function (err) { + destroyed += 1; + if (err) s.emit('error', err); + }; + const ms = new msgpack.Stream(s); + const errors = []; + ms.addListener('error', (e) => errors.push(e)); + const packed = msgpack.pack('hello'); + s.emit('data', packed.subarray(0, packed.length - 1)); + assert.ok(ms.buf); + s.emit('data', hugeChunk(msgpack.MAX_STREAM_BYTES)); + assert.equal(ms.buf, null); + assert.equal(errors.length, 1); + assert.match(errors[0].message, /stream limit exceeded/); + assert.equal(destroyed, 1); + /* Further data is ignored after the cap fires. */ + s.emit('data', msgpack.pack('ok')); + assert.equal(errors.length, 1); + }); + + it('rejects a first chunk longer than MAX_STREAM_BYTES', () => { + const s = new EventEmitter(); + let destroyed = 0; + s.destroy = function (err) { + destroyed += 1; + if (err) s.emit('error', err); + }; + const ms = new msgpack.Stream(s); + const errors = []; + ms.addListener('error', (e) => errors.push(e)); + s.emit('data', hugeChunk(msgpack.MAX_STREAM_BYTES + 1)); + assert.equal(ms.buf, null); + assert.equal(errors.length, 1); + assert.match(errors[0].message, /stream limit exceeded/); + assert.equal(destroyed, 1); + }); + + it('drops buf on close, end, and error of the underlying stream', () => { + const packed = msgpack.pack('hello'); + for (const ev of ['close', 'end', 'error']) { + const s = new EventEmitter(); + const ms = new msgpack.Stream(s); + const errors = []; + ms.addListener('error', (e) => errors.push(e)); + s.emit('data', packed.subarray(0, packed.length - 1)); + assert.ok(ms.buf, ev); + if (ev === 'error') { + const sockErr = new Error('socket down'); + s.emit('error', sockErr); + assert.equal(errors.length, 1, ev); + assert.strictEqual(errors[0], sockErr, ev); + } else { + s.emit(ev); + assert.equal(errors.length, 0, ev); + } + assert.equal(ms.buf, null, ev); + } + }); + it('round-trips over a TCP socket', (t, done) => { const server = net.createServer((c) => { c.write(msgpack.pack('hello ')); diff --git a/test/security.test.js b/test/security.test.js index cd408c7..492d473 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -43,6 +43,20 @@ describe('unpack DoS limits', () => { }); }); +describe('pack container limits', () => { + it('rejects a sparse array whose Length exceeds 1e6', () => { + const a = []; + a.length = 1000001; + assert.throws(() => msgpack.pack(a), /pack limit exceeded/); + }); + + it('rejects a map with more than 1e6 own keys', { timeout: 60000 }, () => { + const o = Object.create(null); + for (let i = 0; i <= 1000000; i++) o[i] = 0; + assert.throws(() => msgpack.pack(o), /pack limit exceeded/); + }); +}); + describe('pack throw paths do not leak (msgpack/msgpack-node#25686)', () => { it('survives many pack failures without crashing', () => { for (let i = 0; i < 20000; i++) { From 97768d9940e34cf27484b7ca692598ff18eb2bc9 Mon Sep 17 00:00:00 2001 From: Enoch Groot Date: Tue, 22 Sep 2026 10:19:30 +0000 Subject: [PATCH 2/3] fix: drop Node 24 from engines and CI InitLazy still calls ObjectTemplate::SetIndexedPropertyHandler, which Node 24 V8 headers no longer provide. Parent never tested 24. engines.node is ^22 so 24 is not advertised. CI matrix is [22] only. --- .github/workflows/ci.yml | 4 ++-- README.md | 12 +++++++----- package-lock.json | 2 +- package.json | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d86f6cb..b4d86f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,10 @@ jobs: fail-fast: false matrix: # windows-2022 ships VS 2022. windows-latest currently has VS 2026 - # (18.x), which node-gyp 10/11 bundled with Node 22/24 reports as + # (18.x), which node-gyp 10/11 bundled with Node 22 reports as # unknown version "undefined" and then fails the native rebuild. os: [ubuntu-latest, macos-latest, windows-2022] - node: [22, 24] + node: [22] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 diff --git a/README.md b/README.md index c0f1e0e..7893370 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ and de-serializes JavaScript values with [MessagePack](https://msgpack.org). Packed output is a `Buffer` and is typically much smaller than JSON. -Version 3.4 requires **Node.js 22+**, vendors **msgpack-c c-7.0.2**, unpacks +Version 3.4 requires **Node.js 22.x**, vendors **msgpack-c c-7.0.2**, unpacks 64-bit integers outside `Number.MAX_SAFE_INTEGER` as `bigint`, accepts optional pack type/family hints, can unpack maps and arrays lazily (`unpack(buf, { lazy: true })`), and applies write backpressure on @@ -170,10 +170,12 @@ npm test npm run coverage ``` -Needs a C/C++ toolchain and Python (node-gyp). GitHub Actions runs Node 22/24 -on Ubuntu, macOS, and windows-2022. `npm run coverage` instruments JavaScript -with c8 and the native addon with gcov, and fails under 95%. Gates and remaining -uncovered lines are documented in [`COVERAGE.md`](COVERAGE.md). +Needs a C/C++ toolchain and Python (node-gyp). GitHub Actions runs Node 22 +on Ubuntu, macOS, and windows-2022. Node 24 is not advertised: lazy unpack +still uses `SetIndexedPropertyHandler`, which Node 24 V8 removed. +`npm run coverage` instruments JavaScript with c8 and the native addon with +gcov, and fails under 95%. Gates and remaining uncovered lines are +documented in [`COVERAGE.md`](COVERAGE.md). ### Command Line Utilities diff --git a/package-lock.json b/package-lock.json index 54783fb..70b689f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "c8": "12.0.0" }, "engines": { - "node": ">=22" + "node": "^22" } }, "node_modules/@bcoe/v8-coverage": { diff --git a/package.json b/package.json index d2a9e10..c0eafa6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "lib": "lib" }, "engines": { - "node": ">=22" + "node": "^22" }, "dependencies": { "nan": "2.28.0" From a50f5d9d8cfb1ad1b83da2ff0802a1737a1379f9 Mon Sep 17 00:00:00 2001 From: Enoch Groot Date: Tue, 22 Sep 2026 10:50:45 +0000 Subject: [PATCH 3/3] fix: cap PackArrayInterpreted at kMaxContainer Snapshot Length() once, throw when n > 1e6, and iterate the frozen n so interpret cannot walk a sparse array past the PackArray policy. --- src/pack_hints.inc | 11 +++++++++-- test/security.test.js | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/pack_hints.inc b/src/pack_hints.inc index 3eefac8..aabaeec 100644 --- a/src/pack_hints.inc +++ b/src/pack_hints.inc @@ -582,13 +582,20 @@ static void PackArrayInterpreted(msgpack_packer* pk, v8::Local arr, throw MsgpackException(Error("Cowardly refusing to pack circular reference")); } /* GCOVR_EXCL_STOP */ Mark(arr); + /* Snapshot Length() once and freeze it for the walk so a growing getter + * cannot extend the loop. Same 1e6 policy as PackArray. */ + uint32_t len = arr->Length(); + if (len > kMaxContainer) { + Unmark(arr); + throw MsgpackException(Error("msgpack pack limit exceeded")); + } try { /* GCOVR_EXCL_BR_START: allocation failure only. */ - if (msgpack_pack_array(pk, arr->Length())) { + if (msgpack_pack_array(pk, len)) { throw MsgpackException(Error("Error serializing object")); } /* GCOVR_EXCL_BR_STOP */ - for (uint32_t i = 0; i < arr->Length(); i++) { + for (uint32_t i = 0; i < len; i++) { v8::Local item = CheckedGet(arr, i); /* interpret always sets recv in FillHintFromObject. */ v8::Local recv = hint.recv; diff --git a/test/security.test.js b/test/security.test.js index 492d473..89cdea6 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -50,6 +50,15 @@ describe('pack container limits', () => { assert.throws(() => msgpack.pack(a), /pack limit exceeded/); }); + it('rejects a sparse array over 1e6 with a no-op interpret', () => { + const a = []; + a.length = 1000001; + assert.throws( + () => msgpack.pack(a, { interpret: () => ({ data: 0 }) }), + /pack limit exceeded/, + ); + }); + it('rejects a map with more than 1e6 own keys', { timeout: 60000 }, () => { const o = Object.create(null); for (let i = 0; i <= 1000000; i++) o[i] = 0;