Skip to content

feat: 2.0.0 security modernization - #1

Closed
enochgroot wants to merge 14 commits into
masterfrom
security/modernize-2.0.0
Closed

enochgroot wants to merge 14 commits into
masterfrom
security/modernize-2.0.0

Conversation

@enochgroot

@enochgroot enochgroot commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Summary

Security-focused 2.0.0 modernization of the Node MessagePack addon on enochgroot/msgpack-node (fork of msgpack/msgpack-node).

  • Vendored msgpack-c c-7.0.2 (e17beb371b59459a13b48e166a11e123bda5bf93), C API only (Boost 1.0).
  • Fail-closed unpack walker plus unpack.c container caps (MSGPACK_NODE_MAX_CONTAINER = 1e6). The bomb dd ff 00 00 00 throws msgpack unpack limit exceeded and does not allocate.
  • RAII sbuffer so pack() throw paths free the buffer (nodejs/node#25686).
  • nan ^2.23.1 (lockfile 2.28.0), node:test instead of nodeunit, engines.node >=18, package version 2.0.0.

Breaking for 2.0: Buffer packs as MessagePack bin (not raw/str); ext types throw on unpack.

See SECURITY.md.

Review round 2 (commit 1b9feae)

Fixes the eight regressions called out on the previous review:

  1. Dates pack as ISO-8601 via toISOString() at every nesting level (not only the top-level wrapper).
  2. toJSON() is honored on nested objects, matching JSON.stringify.
  3. Numeric own keys are packed (V8 returns them as Numbers; they are no longer dropped).
  4. Cycle marks use V8 private symbols, so a user key named _msgpack_stack is kept.
  5. 1e30 (integral but outside uint64) packs as float64, not 2^64-1.
  6. Pack recursion is capped at 512; 8000 nested arrays/objects throw instead of SIGSEGV. Unpack walker depth 512 is backed by MSGPACK_EMBED_STACK_SIZE=512 so 32-deep input no longer dies in the C embed stack.
  7. bin/msgpack2json and bin/json2msgpack no longer use the removed sys / net.Stream APIs; they run on Node 18+.
  8. msgpack.Stream emits error on unpack throw (and drops the bomb) instead of throwing from the data listener; packed null (0xc0) is emitted as a message, not treated as "incomplete".

Test plan

  • npx node-gyp rebuild on Node 20.20.2 linux arm64
  • npm test — 39 passed, 0 failed (includes nested Date/toJSON, numeric keys, 1e30, 8000-deep pack throw, 32/511-deep unpack, CLI bins, Stream nil + unpack error)
  • CI matrix on the PR (.github/workflows/ci.yml: Node 18/20/22 × ubuntu-latest + macos-latest)

Reviewer

@godsflaw — please review. Collaborator invite requested with maintain so you can merge on this fork.

Coverage (2.0.0)

Independent verification on Node 20 linux/arm64 after landing issue PRs #2/#3/#4 on this branch:

  • npm test: 128 tests, 0 fail
  • JS (c8, lib/ + bin/): 100% statements / branches / functions / lines
  • Native (gcovr on src/msgpack.cc only, --exclude-throw-branches --exclude-unreachable-branches): 95.9% lines (473/493), 99.5% branches (400/402), 100% functions (36/36)

CI coverage job (ubuntu-latest, Node 20) fails the PR under 95% for both.

No production logic was deleted. src/msgpack.cc only gained comments (GCOVR_EXCL_BR_*) on branches that cannot be taken from JS without stubbing the allocator or V8. Full remainder list: COVERAGE.md.

Uncovered remainder after those documented exclusions:

  • Skip() after CheckBytes already proved the payload fits (gcovr lines 148 and 575)
  • NAN_MODULE_WORKER_ENABLED compile-time #else on this NAN 2.23.0 tree
  • Allocator-failure / empty-MaybeLocal / post-ScanOne unpack-error arms (comments only)

Do not merge this into msgpack/msgpack-node. This is the 2.0.0 PR on the fork.

enochgroot and others added 13 commits September 10, 2026 00:02
Vendor msgpack-c c-7.0.2, fail-closed unpack limits, and fix the
sbuffer leak on pack throw (nodejs/node#25686). Replace nodeunit with
node:test, require Node 18+, and run GitHub Actions on 18/20/22.
Dates and toJSON apply at every nesting level. Numeric object keys
are packed instead of dropped. Cycle marks use V8 private symbols
so a user key named _msgpack_stack is no longer stripped. Integral
doubles outside uint64/int64 range (e.g. 1e30) pack as float64.
Pack recursion is capped at 512 so 8000-deep input throws instead
of SIGSEGV. The vendored C unpacker is built with
MSGPACK_EMBED_STACK_SIZE=512 so advertised unpack depth matches
the walker. Stream emits error on unpack throw and treats packed
nil as a message. The CLI bins run on Node 18+.
DefineOwnProperty on map keys so a wire __proto__ cannot replace
the decoded object's prototype. Property reads during pack go
through Nan::TryCatch so a throwing getter or Proxy ownKeys
raises a catchable error instead of aborting on ToLocalChecked.
Stream snapshots bytes_remaining and advances the buffer before
emit('msg'), so a listener that unpacks or throws cannot desync
or replay a frame.
pack() ran a JS pre-pass that called toJSON() on any top-level object.
Buffer.prototype.toJSON exists, so msgpack.pack(Buffer.from([1,2,3]))
emitted a {type:'Buffer',data:[...]} map instead of msgpack bin, and
unpack() no longer returned a Buffer. Nested Buffers were unaffected
because they went straight to the binding.

The pre-pass is redundant: the native JsToMsgpack checks
node::Buffer::HasInstance before the generic object path, PackObject
applies toJSON at every level, and Dates pack as ISO strings natively.
Drop it and forward arguments to the binding unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NODE_MODULE is not context-aware, so requiring msgpack in the main
thread then in a Worker throws "Module did not self-register".

Register with NODE_MODULE_CONTEXT_AWARE and keep the sbuffer pool,
unpack remainder, and cycle-detection key thread_local so workers
do not race the main isolate.

Fixes msgpack#60
Packed integer 0 must emit on Stream (msgpack-node#44). A map written
by python-msgpack with a bin8 payload must unpack Payload as Buffer
(msgpack-node#10). Both already work on 2.0.0; these tests keep them so.

Refs msgpack#10
Refs msgpack#44
Adds index.d.ts matching the 2.0.0 runtime: pack(...values) returns
Buffer, unpack.bytes_remaining is per-thread, Stream wraps a duplex.

Fixes msgpack#39
Address review on PR #2 for worker_threads (msgpack#60):

- Mark sbuf_pool thread_local so a worker pack cannot share the pool
- Register with NAN_MODULE_WORKER_ENABLED instead of NODE_MODULE_CONTEXT_AWARE
- Return non-pooled sbuffers to the pool so it actually fills
- Add concurrent pack (workers + main) and sequential pool-reuse tests
lib/ and bin/ reach 100% statements/branches/functions/lines under c8;
src/msgpack.cc reaches 95.9% lines and 99.5% branches under gcovr.

New tests:

- test/coverage-native.test.js walks every MessagePack format family from
  hand-built wire bytes, including the ones pack() never emits (float32,
  str8/16/32, bin16/32, array32, map16/32, all eight ext forms, negative
  fixint); a truncation point for every header and payload; the kMaxBytes,
  kMaxContainer and kMaxDepth rejections; 0xc1; the pack-side type dispatch
  (Symbol, BigInt, non-finite numbers, integer edges, undefined, zero- and
  multi-argument pack); Date and toJSON failure modes with mark cleanup; and
  a worker nesting 600 packs deep to saturate the thread-local sbuffer pool
  and reach the "pool is full, free it" arm of ~PackBuffer.
- test/cli.test.js covers the exit-1 paths of both CLIs. The pack-failure arm
  of json2msgpack is reachable from real JSON only through nesting deeper
  than the 512-level pack cap; every other pack error needs a value
  JSON.parse cannot produce.

Infrastructure:

- binding.gyp grows an msgpack_coverage variable, default 0. Only when it is
  set to 1 does the addon compile and link with --coverage -O0 -g, so
  npm install and node-gyp rebuild stay uninstrumented.
- npm run coverage runs coverage:js (c8, gated at 95% on all four metrics)
  then coverage:native (scripts/coverage-native.js). The native script
  rebuilds instrumented, runs the suite, gates on gcovr --fail-under-line 95
  --fail-under-branch 95 over src/ excluding deps/, and always rebuilds
  uninstrumented afterwards -- including when the gate fails.

src/msgpack.cc gains comments only. GCOVR_EXCL_BR markers, each with its
reason inline, mark branches unreachable without stubbing malloc or V8:
allocation-failure arms of msgpack_pack_*, empty-MaybeLocal guards, Skip()
calls a preceding CheckBytes has already proved safe, and the post-ScanOne
error tail of Unpack. No production code was removed. COVERAGE.md lists all
45 marked lines, the 20 still-uncovered lines, the 2 still-uncovered branches
and the un-gated numbers (70.6% raw, 86.3% throw-excluded, 99.5% as shipped).
Adds an ubuntu-latest / Node 20 job that installs gcovr via pip when it is
not already present and runs npm run coverage. Both halves are threshold-
gated, so the job fails when JS or native coverage drops below 95%. The
existing node 18/20/22 x ubuntu/macos test matrix is unchanged.
Acceptance requires the coverage script in the Building section
and a pointer to COVERAGE.md for gates and remainder.
Comment on lines +6 to +8
* Distributed under the Boost Software License, Version 1.0.
* (See accompanying file LICENSE_1_0.txt or copy at
* http://www.boost.org/LICENSE_1_0.txt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you change these license lines? It was Apache 2.0 and now there is what, some Boost license?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept Boost. zone.h (and the rest of vendored msgpack-c c-7.0.2) is Boost Software License 1.0. msgpack-c relicensed Apache-2.0 → BSL-1.0 in 1.3.0 (2015-11-21); see msgpack/msgpack-c#366 and CHANGELOG msgpack#386. This binding does not restate those headers as Apache. Native first-party code stays BSD-3-Clause (the original node-msgpack license).

Comment thread deps/msgpack/LICENSE
Comment on lines +1 to +23
Boost Software License - Version 1.0 - August 17th, 2003

Permission is hereby granted, free of charge, to any person or organization
obtaining a copy of the software and accompanying documentation covered by
this license (the "Software") to use, reproduce, display, distribute,
execute, and transmit the Software, and to prepare derivative works of the
Software, and to permit third-parties to whom the Software is furnished to
do so, all subject to the following:

The copyright notices in the Software and this entire statement, including
the above license grant, this restriction and the following disclaimer,
must be included in all copies of the Software, in whole or in part, and
all derivative works of the Software, unless such copies or derivative
works are solely in the form of machine-executable object code generated by
a source language processor.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same license question. This seems like a big change without good justification.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — that file is the current c-7.0.2 LICENSE, Boost Software License 1.0. msgpack-c left Apache-2.0 in 1.3.0 (2015-11-21). Documented in root LICENSE and SECURITY.md so readers do not have to hunt the C changelog.

Comment thread LICENSE Outdated
and deps/msgpack/NOTICE for further information>
. MessagePack C library, located in deps/msgpack/.

Vendored from msgpack-c c-7.0.2, licensed under the Boost Software

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, so the C library license changed?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The C library's license changed; this binding's did not. Root LICENSE now states: addon remains BSD-3-Clause; vendored msgpack-c is BSL-1.0 as of 1.3.0 (2015-11-21). Native first-party code is not relicensed to Apache-2.0.

Comment thread README.md Outdated

Note that `node-msgpack` produces and consumes Buffer objects, and a such does
not incur encoding/decoding overhead when performing I/O with native strings.
This tree is a security-focused 2.0 fork (`enochgroot/msgpack-node`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be merged to upstream master, I would leave mentions of your personal enochgroot repo out of this.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. README now reads as the upstream 2.0.0 tree — no personal-fork branding. package.json homepage/repository and index.d.ts point at msgpack/msgpack-node.

Comment thread README.md
Comment on lines -9 to -27
`node-msgpack` is currently slower than the built-in `JSON.stringify()` and
`JSON.parse()` methods. In recent versions of node.js, the JSON functions
have been heavily optimized. node-msgpack is still more compact, and we are
currently working performance improvements. Testing shows that, over 500k
iterations, `msgpack.pack()` is about 5x slower than `JSON.stringify()`, and
`msgpack.unpack()` is about 3.5x slower than `JSON.parse()`.

Old performance numbers are below.

The following tests were performed with 500,000 instances of
the JavaScript object `{'abcdef' : 1, 'qqq' : 13, '19' : [1, 2, 3, 4]}`:

* `JSON.stringify()` 7.17 seconds
* `JSON.parse(JSON.stringify())` 22.18 seconds
* `msgpack.pack()` 5.80 seconds
* `msgpack.unpack(msgpack.pack())` 8.62 seconds

Note that `node-msgpack` produces and consumes Buffer objects, and a such does
not incur encoding/decoding overhead when performing I/O with native strings.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's re-test this benchmarking.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-tested. npm run bench on Node v20.20.2, Debian 12, linuxkit aarch64: JSON.stringify 179 ms, JSON.parse(JSON.stringify()) 346 ms, msgpack.pack 1021 ms, msgpack.unpack(msgpack.pack()) 1585 ms (500k iterations of the documented object). Current numbers are in README. The 2011 figures are gone. On this small object V8 JSON is faster; msgpack's win here is payload size.

Comment thread README.md
Comment on lines -117 to -119
As a convenience and for debugging, `bin/json2msgpack` and `bin/msgpack2json`
are provided to convert JSON data to and from MessagePack data, reading from
stdin and writing to stdout.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you get rid of these?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No — they are still here. bin/json2msgpack and bin/msgpack2json still ship. Restored the Command Line Utilities section in README with Node 18+ examples. Verified: echo '[1, 2, 3]' | ./bin/json2msgpack | ./bin/msgpack2json.

Comment thread README.md
Comment on lines -178 to -180
To run benchmarks:
### License

./run_tests test/benchmark/benchmark.js

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't remove the benchmarking, update it and document the current benchmarks.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored. README has a Benchmarks section with how to run (npm run bench / node test/benchmark/benchmark.js) and the current numbers. The runner is a Node 18+ script (no nodeunit). test/benchmark/ was not deleted.

Strip personal-fork branding so this reads as the upstream 2.0.0
tree. Document that vendored msgpack-c c-7.0.2 is Boost Software
License 1.0 (relicensed from Apache-2.0 in msgpack-c 1.3.0). Restore
Command Line Utilities and Benchmarks, refresh the bench runner for
Node 18+, and record current numbers.
@enochgroot

Copy link
Copy Markdown
Owner Author

Superseded: opened against msgpack/msgpack-node:master.

msgpack#25687

Review comments on license, README branding, CLI, and benchmarks are addressed on security/modernize-2.0.0 (3c7f039).

@enochgroot enochgroot closed this Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants