Skip to content

node:vm: report cachedData of 2 GiB or more as rejected instead of aborting - #42734

Open
robobun wants to merge 1 commit into
mainfrom
robobun/0289c28a/vm-cacheddata-length
Open

robobun wants to merge 1 commit into
mainfrom
robobun/0289c28a/vm-cacheddata-length

Conversation

@robobun

@robobun robobun commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • new vm.Script("1", { cachedData: new Uint8Array(2 ** 31) }) aborts: panic(main thread): abort() called, exit 134. It costs 0.1 s and 30 MB of RSS: nothing writes to the buffer. vm.compileFunction and new vm.SourceTextModule abort too.
  • The cause is extractCachedData (src/jsc/bindings/NodeVM.cpp:111). It copies the option into a WTF::Vector<uint8_t>, which holds at most INT_MAX bytes. VectorBufferBase::allocateBuffer<FailureAction::Crash> calls CRASH() for more.

Fix

  • extractCachedData checks the length with WTF::isValidCapacityForVector<uint8_t> before the copy. It does not copy a longer buffer, and the three entry points report it as rejected without a decode: cachedDataRejected === true for Script and compileFunction, ERR_VM_MODULE_CACHED_DATA_REJECTED for SourceTextModule (after the syntax check).
  • Correct because Node v26.3.0 prints the same output for the same script (see the notes).
  • Verified: test/js/node/vm/vm.test.ts. The new test aborts on main and passes here, with BUN_JSC_validateExceptionChecks=1 too. The 97 test-vm-*.js Node tests pass.
  • Self-reviewed: 11 concerns raised, 9 addressed. Rejected, with reasons in the notes: a lowered test limit through Bun::maxVectorSize, and an if wrapper in place of the second throwError.

Background

  • cachedData is the serialized bytecode that script.createCachedData() returns. A constructor decodes it and sets cachedDataRejected when the decode fails.
  • The bindings copy the bytes when they validate the options. Later option getters can run JS that detaches the buffer.
  • Node narrows the length to the int of V8's ScriptCompiler::CachedData. V8 then rejects bytes that do not start with a valid cache header. Bun rejects by the length and copies nothing.
Notes

Node v26.3.0 and this branch print the same line for the script below (node --experimental-vm-modules). It is the fixture of the new test with two changes for Node: a Buffer in place of the bare ArrayBuffer (Node refuses one with ERR_INVALID_ARG_TYPE, Bun accepts one and that arm aborted too), and a distinct source per call (V8 skips cachedData for a source that is already in its compilation cache).

const vm = require("node:vm");
const buffer = new ArrayBuffer(2 ** 31);
const codeOrName = fn => { try { return fn(); } catch (e) { return e.code ?? e.name; } };
const result = {};
for (const cachedData of [new Uint8Array(buffer), new DataView(buffer), Buffer.from(buffer)]) {
  const name = cachedData.constructor.name;
  const script = new vm.Script("1 + 1 // " + name, { cachedData });
  const fn = vm.compileFunction("return 2 + 2 // " + name, [], { cachedData });
  result[name] = {
    Script: [script.cachedDataRejected, script.runInThisContext()],
    compileFunction: [fn.cachedDataRejected, fn()],
  };
}
const cachedData = new Uint8Array(buffer);
result.SourceTextModule = codeOrName(() => new vm.SourceTextModule("export default 1", { cachedData }).status);
result.SourceTextModuleWithSyntaxError = codeOrName(() => new vm.SourceTextModule("export default", { cachedData }).status);
console.log(JSON.stringify(result));
{"Uint8Array":{"Script":[true,2],"compileFunction":[true,4]},"DataView":{"Script":[true,2],"compileFunction":[true,4]},"Buffer":{"Script":[true,2],"compileFunction":[true,4]},"SourceTextModule":"ERR_VM_MODULE_CACHED_DATA_REJECTED","SourceTextModuleWithSyntaxError":"SyntaxError"}

How Node gets there (v26.3.0). src/node_contextify.cc:1027-1028 passes cached_data_buf->ByteLength() to ScriptCompiler::CachedData(const uint8_t*, int length) (deps/v8/include/v8-script.h:470). 2^31 becomes INT_MIN. V8 stores it as uint32_t size_ (deps/v8/src/snapshot/snapshot-data.h:65), so the size check in SerializedCodeData::SanityCheckWithoutSource (code-serializer.cc:788) passes and the magic number check rejects the zero bytes. Modules throw at src/module_wrap.cc:419.

Where Bun now differs from Node, on purpose.

  • A buffer of 2 GiB or more that starts with a valid cache. V8 accepts a payload that is shorter than the buffer, so Node reports cachedDataRejected === false. Bun reports true. No producer emits such a buffer, and to match Node here Bun must copy 2 GiB or more.
  • A 2 GiB view that is not pointer-aligned, for example new Uint8Array(new ArrayBuffer(2 ** 31 + 8), 1, 2 ** 31). Node aborts: FATAL ERROR: NewArray Allocation failed - process out of memory (V8 copies unaligned data, with the negative length). Bun reports true.

Why rejected and not a RangeError. Node returns a usable Script and compiles from source. A throw would turn working Node code into an exception. A buffer of 2^31 - 1 bytes already gave these results in Bun. The state costs one bool next to the copied bytes, placed so that ScriptOptions and CompileFunctionOptions stay at 64 bytes.

The test runs a child that reserves one 2 GiB ArrayBuffer and passes it as a Uint8Array, a DataView and a bare ArrayBuffer. It follows the 2 GiB tests in web-crypto.test.ts and sqlite.test.js: the child prints SKIP when it cannot reserve the memory. On the debug ASAN build the reservation takes 5 ms and the test 0.5 s.

  • There is no test at exactly 2^31 - 1 bytes. That side of the bound copies 2 GiB for real, and the observable result is the same on both sides.
  • The test does not lower the bound with setSyntheticAllocationLimitForTesting (through Bun::maxVectorSize from VectorSizeLimit.h). Below the real bound, the unfixed code copies the bytes and the decoder rejects them, so both builds print the same result. A difference would need a valid cache that is padded to the limit and still accepted, and node:vm: reject invalid cachedData instead of crashing #32839 makes the decoder reject padded data. The abort at the real bound is the one stable observable, and it is cheap to reach.
  • The test does not cover "a syntax error wins over rejected cachedData" for modules, although this branch keeps that order (see the output above). With BUN_JSC_validateExceptionChecks=1, which the ASAN lane sets, every module with a syntax error and a non-empty cachedData trips Unchecked JS exception ... getUnlinkedCodeBlock @ ModuleProgramExecutable.cpp:61. A 10 byte cachedData does it on main. Bump WebKit so new Function with a syntax error survives validateExceptionChecks #40866 fixes that.

Unchanged on purpose. An empty cachedData still counts as absent (Node reports it as rejected), and produceCachedData is still ignored when cachedData is given. A general "cachedData was provided" bit in place of cachedDataTooLong would also change the result for an empty cachedData. That change belongs to #32839.

Other cachedData work in flight. #42229 gives the payload to the CachedBytecode that decodes it. #32839 validates a header before the decode. #41769 drops the baseline compile in constructScript. The first two change the CachedBytecode::create(std::span(cachedData), ...) lines, and the third edits the block around one of them. This change does not touch those lines. None of the three bounds the length or removes the copy in extractCachedData. Whichever lands second rebases.

The same class in other places. #42648 tracks containers that abort when script makes them too large. One more site has no owner: buffer.transcode(new Uint8Array(2 ** 31), "latin1", "utf16le") aborts in jsBufferTranscode (src/jsc/modules/NodeBufferModule.cpp:131, result.grow(length * 2) on a WTF::Vector<uint8_t>). It is a different module, so it is not in this change.

A different abort in the same file, also not in this change. const p = Buffer.alloc(2 ** 30, "a").toString("latin1"); vm.compileFunction("", [p, p]) aborts in stringifyAnonymousFunction (NodeVM.cpp:424): the StringBuilder for the parameter list crashes when it passes the string length limit. It needs 2 GiB of real string memory, so it is the string limit class (#37215), not this one, and it has no cheap test.

The second throwError in NodeVMSourceTextModule.cpp. An if (!cachedDataTooLong) { ... } around the decode gives one throw site, but it re-indents the lines that #42229 and #32839 edit. The early reject leaves them byte-identical to main.

Found by a fuzzing run. It reproduces on 1.3.14 through 1.4.2, canary and main, so it is not a regression.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/vm/vm.test.ts

…orting

extractCachedData copied the option into a WTF::Vector, which holds at
most INT_MAX bytes and crashes on a larger capacity. It now checks the
length before the copy. new vm.Script and vm.compileFunction set
cachedDataRejected, and new vm.SourceTextModule throws
ERR_VM_MODULE_CACHED_DATA_REJECTED. Node gives the same results for a
buffer that long, unless the buffer starts with a valid cache.
@robobun

robobun commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduction, on a release build of main (09bb54630, linux x64):

const vm = require("node:vm");
new vm.Script("1", { cachedData: new Uint8Array(2 ** 31) });

The process aborts with panic(main thread): abort() called, exit 134.

  • USE_SYSTEM_BUN=1 bun test test/js/node/vm/vm.test.ts -t "cachedData of 2 GiB" fails with that panic.
  • bun bd test test/js/node/vm/vm.test.ts passes on this branch: 282 pass, 0 fail.

The fix is in this PR (#42734).

@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 363457eb-3d7b-44bf-a271-d54f3ead5077

📥 Commits

Reviewing files that changed from the base of the PR and between 5fce36e and d8605c3.

📒 Files selected for processing (6)
  • src/jsc/bindings/NodeVM.cpp
  • src/jsc/bindings/NodeVM.h
  • src/jsc/bindings/NodeVMScript.cpp
  • src/jsc/bindings/NodeVMScript.h
  • src/jsc/bindings/NodeVMSourceTextModule.cpp
  • test/js/node/vm/vm.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

Cached-data extraction now distinguishes invalid, copied, and oversized inputs. Script, compile-function, and source-text-module paths propagate oversized state and reject the data safely. A subprocess regression test covers 2 GiB-or-larger buffers.

Changes

Cached-data handling

Layer / File(s) Summary
Extraction and validation contract
src/jsc/bindings/NodeVM.h, src/jsc/bindings/NodeVM.cpp
Cached-data extraction returns structured status values. Detached views and oversized vectors are rejected without copying. Validation records oversized input separately from invalid buffer types.
Script and compile-function rejection
src/jsc/bindings/NodeVMScript.h, src/jsc/bindings/NodeVMScript.cpp, src/jsc/bindings/NodeVM.cpp
Script and compile-function options retain the oversized state. Both paths reject oversized cached data before bytecode decoding or compilation.
Module rejection and regression coverage
src/jsc/bindings/NodeVMSourceTextModule.cpp, test/js/node/vm/vm.test.ts
Source text modules perform syntax validation before rejecting oversized cached data. The subprocess test covers Uint8Array, DataView, and bare ArrayBuffer inputs across the three APIs.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to d8605

Oversized cached data is rejected safely across the supported VM APIs, with no actionable merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary change: oversized cachedData buffers are reported as rejected instead of causing an abort.
Description check ✅ Passed The description explains the problem, implementation, expected behavior, verification steps, test coverage, and known differences. It does not use the exact template headings, but it provides the requ…

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — a narrow bounds check that turns a user-reachable abort into the same rejected result Node reports.

What was reviewed:

  • extractCachedData now takes a span first and gates the Vector copy on isValidCapacityForVector<uint8_t>; the tri-state enum is exhaustively switched at all three call sites and the cachedDataTooLong field is default-initialized in both option structs and the SourceTextModule local.
  • All three sibling entry points (vm.Script, vm.compileFunction, SourceTextModule) are covered; the too-long branch falls through to compile-from-source with cachedDataRejected = true for the first two and throws ERR_VM_MODULE_CACHED_DATA_REJECTED after the syntax check for modules, matching the cited Node ordering.
  • No new JS-entering calls are introduced under the existing ThrowScopes, so exception-check discipline is unchanged.
  • Test follows harness conventions: subprocess via bunExe -e, {...bunEnv, ASAN_OPTIONS: ...}, Promise.all pipe drain, combined-object assertion before exitCode, and address-space-only 2 GiB reservation with a SKIP path.
Extended reasoning...

Overview

This PR fixes a process abort in node:vm when cachedData of 2 GiB or more is passed to vm.Script, vm.compileFunction, or vm.SourceTextModule. The root cause was extractCachedData in src/jsc/bindings/NodeVM.cpp copying the buffer into a WTF::Vector<uint8_t>, whose allocator crashes past INT_MAX. The fix converts the helper's bool return to a three-value CachedDataExtraction enum, checks WTF::isValidCapacityForVector<uint8_t> before the copy, and threads a cachedDataTooLong flag through BaseVMOptions::validateCachedData into ScriptOptions/CompileFunctionOptions and a local in NodeVMSourceTextModule::create. Callers set cachedDataRejected = true (Script/compileFunction) or throw ERR_VM_MODULE_CACHED_DATA_REJECTED (SourceTextModule, after the syntax check) instead of attempting the decode. A new subprocess test in test/js/node/vm/vm.test.ts reserves a 2 GiB ArrayBuffer (address space only) and asserts rejection for Uint8Array, DataView, and bare ArrayBuffer variants.

Security risks

None introduced. The change is a defensive hardening: it adds a bounds check on a user-supplied buffer length before allocation, converting a DoS-class abort into a recoverable error. No new allocations, no GC-visible objects, no ref-count changes, and no new JS-entering calls are added under the existing ThrowScopes. The span is taken from the buffer view synchronously and copied (or not) before any user code can run again, so no new detach hazard.

Level of scrutiny

Moderate. C++ JSC bindings are memory-safety-sensitive, but this diff is small (~90 lines across 6 files), mechanical, and adds — rather than removes — a guard. It is exactly the "treat all size/index/length arithmetic on external data as adversarial" pattern REVIEW.md calls for, and the "user-reachable failures are recoverable errors, never panics" rule. I verified the enum is exhaustively handled at every switch, the new bool fields default to false at every declaration site, and the too-long branch in constructAnonymousFunction leaves unlinkedProgramCodeBlock null so control falls through to the compile-from-source path with bytecodeAccepted = TriState::False.

Other factors

The bug hunt ran to dry_streak with no findings. No CODEOWNERS entry covers these paths. The PR description cites the exact Node source lines (node_contextify.cc:1027-1028, module_wrap.cc:419) that produce the same observable behavior, and the test extends the existing test/js/node/vm/vm.test.ts per the test-organization rule. The test follows every harness convention I checked: bunExe()/bunEnv spread, ASAN_OPTIONS appended (not overwritten), concurrent pipe drain via Promise.all, combined {stdout, stderr} object assertion before exitCode, and a graceful SKIP when the 2 GiB reservation fails. No outstanding third-party reviews on the timeline.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant