Skip to content

node:vm: reject invalid cachedData instead of crashing - #32839

Open
robobun wants to merge 4 commits into
mainfrom
farm/4b1d3dba/node-vm-cacheddata-validate
Open

robobun wants to merge 4 commits into
mainfrom
farm/4b1d3dba/node-vm-cacheddata-validate

Conversation

@robobun

@robobun robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • new vm.Script, vm.compileFunction and new vm.SourceTextModule give cachedData to JSC::decodeCodeBlock() unchecked. One changed byte can crash: panic(main thread): Segmentation fault at address 0x818585C26A4. ASAN: heap-buffer-overflow in VariableLengthObject<StringImpl*>::isEmpty, or ASSERTION FAILED: addr >= cachedBytecodeSpan.data() in JSC::Decoder::offsetOf.
  • Main 8cc039765e, one process per single-byte flip of an 888-byte blob: 262 die, 590 are accepted and run the damaged bytecode, 36 are rejected.
  • The decoder trusts its payload by design: [JSC] Bytecode cache: drop the payload damage checks and always-on options; link resolves each variable read once; inline one-byte varints WebKit#677 removed its damage checks for speed. Nothing checks the bytes first.

Fix

  • createCachedData() and produceCachedData prepend a 24-byte header: { magic, payloadLength, sourceHash, jscVersion, payloadHash } (XXH3-64 of the bytecode).
  • All three call unwrapCachedData(), which checks each field before the decoder sees the payload. A failed check takes the existing rejection path (cachedDataRejected === true, or ERR_VM_MODULE_CACHED_DATA_REJECTED), and the source compiles normally.
  • Correct because the decoder only sees bytes this build wrote for this source. cachedData was never portable between builds, so no working cache breaks.
  • Verified: test/js/node/vm/vm.test.ts. The fix rejects 2464 of 2464 flips and 616 of 616 truncations. Also bundler_bytecode_portable.test.ts and Node's vm cachedData tests.

Background

  • cachedData is a serialized UnlinkedCodeBlock: JSC bytecode not yet linked to a global object.
  • Its objects point at each other with self-relative offsets. The decoder computes this + offset and reads. It is built for caches Bun writes itself.
  • V8 guards the same input with a header (magic, version, source hash, length, checksum). A mismatch sets cachedDataRejected. Release Node skips the checksum unless --verify-snapshot-checksum is set, so a flipped byte crashes it too.
Notes

Reproduction. One process per offset, because a crash ends the process:

// bun flip.mjs <offset>
import vm from "node:vm";
const src = "function f(a,b){ return [a+b, `${a}-${b}`, {a,b}] }; globalThis.R = JSON.stringify([f(2,3), [1,2,3].map(x=>x*2)]); R";
const cd = Buffer.from(new vm.Script(src).createCachedData());
cd[Number(process.argv[2])] ^= 0xff;
const s = new vm.Script(src, { cachedData: cd });
console.log(s.cachedDataRejected, s.runInNewContext());

Node, for comparison. V8 does not read cachedData when the same source was already compiled in the process, so the producer and the consumer must be separate processes. Node v26.3.0, a 744-byte blob written by one process, one consumer process per mutation:

  • Truncations at 0, 4, 8, ... bytes: 186 of 186 rejected, right result.
  • Single-byte flips, 744 offsets: 23 rejected, 410 accepted with the right result, 24 accepted with a wrong result (for example [2,33423364,6] in place of [2,4,6]), 23 threw a JS error, 264 crashed (V8 fatal error, SIGSEGV, or "V8 sandbox violation detected").
  • With --verify-snapshot-checksum, five of the offsets that crash or give a wrong result by default are all rejected with the right result. The flag is off by default in release builds.

So Node's default catches truncated, foreign and stale blobs, and does not catch a flipped byte. This PR catches both. The cost is one XXH3-64 pass over the blob: 0.35 ms for the 11.8 MB cachedData of typescript.js (Bun.hash.xxHash3, release build, this machine), next to about 10 ms for new vm.Script(src, { cachedData }) of the same file on main.

Sweep, same machine, same tree. Debug build with ASAN, Malloc=1 so that ASAN sees JSC's allocations, one fresh process per mutation. "Main" is this branch with src/ from main 8cc039765e (WebKit 63a807e88ce0). Only the five NodeVM* files differ.

vm.Script, every single-byte flip main src/ (888-byte blob) this branch (912-byte blob)
rejected, right result, exit 0 36 912
accepted (cachedDataRejected === false), exit 0 590 0
died 262 0

The 262 deaths on main: 124 engine assertions, 77 ASAN heap-buffer-overflow, 32 ASAN SEGV, 21 SHOULD NEVER BE REACHED, 1 ASAN heap-use-after-free, 3 killed by a 60 s bound, 4 other. The 590 accepted blobs happened to give the right result for this source. Nothing guarantees that: the engine links and runs bytecode that differs from what it wrote.

This branch, all three entry points, every single-byte flip plus every truncation at 0, 4, 8, ... bytes:

entry point blob flips rejected truncations rejected intact blob
new vm.Script 912 912 of 912 228 of 228 accepted
vm.compileFunction 616 616 of 616 154 of 154 accepted
new vm.SourceTextModule 936 936 of 936 234 of 234 accepted

Every process exits 0 with the right result and an empty stderr. A rejected module means ERR_VM_MODULE_CACHED_DATA_REJECTED.

Truncation. This PR first showed the bug with truncated blobs (a partial write or a stale file), which crashed main at that time: new vm.Script(src, { cachedData: cd.subarray(0, cd.length >> 1) }). #43616 brought in oven-sh/WebKit#707, where a payload records its size and a shorter one is a cache miss. So main now rejects truncated blobs. The same bump brought in oven-sh/WebKit#677, which removed the per-record damage checks from the decoder on purpose: "a payload is trusted as a whole". Interior damage is therefore the caller's problem, and node:vm is the one caller that accepts bytes from user code. The header's payloadLength still rejects truncation and extension before any hashing.

Why the check is not in the decoder. An earlier attempt added bounds checks to Decoder::offsetOf, ptrForOffsetFromBase and CachedPtr::decode (oven-sh/WebKit#368). It is closed: oven-sh/WebKit#677 went the other way for decode speed, and bounds checks do not catch a flipped byte that stays in range. A checksum over the whole payload does.

Header fields. magic rejects buffers that Bun did not write. payloadLength rejects truncation and extension. sourceHash rejects a blob for other source text. jscVersion is computeJSCBytecodeCacheVersion() and rejects a blob from another build. payloadHash rejects interior damage. An accepted payload is copied into memory that the CachedBytecode owns (createOwnedCachedBytecode(), from #42229), because JSC decodes function bodies lazily from it.

Empty buffer. A provided but empty cachedData is rejected, as in Node. Main treats it as absent.

Merges with main.

Tests. rejects corrupted cachedData instead of crashing in test/js/node/vm/vm.test.ts runs truncations at len-1, len/2, 16 and 1, 20 single-byte flips spread over the buffer, an extended buffer, a filled buffer, unrelated bytes and an intact blob for other source, through all three entry points in a child process. Against main's src/ it fails at the first flip that main accepts (AssertionError: flip@64, false !== true). Local runs on the merge with main 8cc039765e, debug build with ASAN: vm.test.ts 303 pass and 0 fail, bundler_bytecode_portable.test.ts 22 pass and 0 fail (with a long timeout, the debug build is slow), Node's test-vm-cached-data.js, test-vm-createcacheddata.js, test-vm-module-cached-data.js and test-vm-basic.js pass.


no test proof · iteration 21 · 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, test/bundler/bundler_bytecode_portable.test.ts

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:54 AM PT - Sep 15th, 2026

✅ @robobun, your commit 29eca57a1e3b59c56fd1e8edbabca7fedf6f6728 passed in Build #115888! 🎉


🧪   To try this PR locally:

bunx bun-pr 32839

That installs a local version of the PR into your bun-32839 executable, so you can run:

bun-32839 --bun

@coderabbitai

coderabbitai Bot commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

NodeVM cached-data serialization now uses a validated header. Script, function, and module readers validate the header before decoding. Writers emit the wrapped format. Tests cover corrupted, mismatched, and intact cached data.

Changes

Cached-data hardening

Layer / File(s) Summary
Cached-data helpers
src/jsc/bindings/NodeVM.h, src/jsc/bindings/NodeVM.cpp
Adds cached-data wrapping and unwrapping with magic, length, source hash, JSC version, and payload hash validation.
Script and compileFunction paths
src/jsc/bindings/NodeVM.cpp, src/jsc/bindings/NodeVMScript.cpp, src/jsc/bindings/NodeVMScript.h
Validates cached data before decoding. Production paths emit wrapped data and handle failed bytecode generation.
SourceTextModule path
src/jsc/bindings/NodeVMSourceTextModule.cpp
Validates cached data before decoding and reports serialization failure before creating wrapped cached data.
Corruption tests
test/js/node/vm/vm.test.ts
Tests truncated, modified, extended, zero-filled, random, and source-mismatched cached data in a child process. It verifies rejection, fallback execution, and intact-data acceptance.

Suggested reviewers: cirospaciari, jarred-sumner

🚥 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 main change: invalid node:vm cachedData is rejected instead of causing crashes.
Description check ✅ Passed The description explains the problem, implementation, affected APIs, rejection behavior, and verification results. Although it does not use the template headings exactly, it provides the required cont…

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/NodeVM.cpp`:
- Around line 144-179: The cached-data path in unwrapCachedData and
decodeCachedData is relying on a forgeable hash in CachedDataHeader, so
untrusted input can still reach decodeCodeBlock. Replace the public
checksum-only check in createCachedDataBuffer/hashCachedDataPayload with an
unforgeable validation approach (for example, a secret-backed tag or structural
validation) and ensure decodeCachedData rejects any payload that is not verified
before decoding. Keep the fix localized around CachedDataHeader,
unwrapCachedData, and decodeCachedData so malformed cachedData cannot proceed
past validation.
- Around line 312-315: The `NodeVM::createCachedDataBuffer()` result can be null
on allocation failure, but the current `function->putDirect()` path still stores
it and sets `cachedDataProduced` to true. In the `NodeVM.cpp` bytecode caching
flow, add a null check immediately after calling `createCachedDataBuffer()` and
before the `putDirect()` calls, mirroring the existing
`RETURN_IF_EXCEPTION`-style guarded paths. If the buffer is null, skip setting
`cachedData` and ensure `cachedDataProduced` is not marked successful.

In `@src/jsc/bindings/NodeVMSourceTextModule.cpp`:
- Around line 510-514: The cached-data path in
NodeVMSourceTextModule::cachedData() needs null checks for both bytecode() and
createCachedDataBuffer(). After calling bytecode(globalObject), verify the
RefPtr is non-null before using cachedBytecode->span(), and similarly verify the
JSUint8Array* returned by createCachedDataBuffer() before storing it in
m_cachedBytecodeBuffer. Keep the existing RETURN_IF_EXCEPTION checks, but add
early returns for the nullptr cases so the function never dereferences or caches
a missing bytecode/buffer.

In `@test/js/node/vm/vm.test.ts`:
- Around line 814-821: The crash-regression assertion in the `vm.test.ts` spawn
test is only checking `stdout` and `exitCode`, which hides useful failure
diagnostics when the child aborts. Update the `Bun.spawn` result handling in
this test to include `stderr` and `signalCode` in the asserted object, using the
existing `stderr` capture from `proc.stderr.text()` so regressions surface
native crash output and termination signals in the diff.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7cdcdd3d-5b22-4daf-86b5-f6ab81bb7043

📥 Commits

Reviewing files that changed from the base of the PR and between df92f8f and aa2e6e7.

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

Comment thread src/jsc/bindings/NodeVM.cpp
Comment thread src/jsc/bindings/NodeVM.cpp Outdated
Comment thread src/jsc/bindings/NodeVMSourceTextModule.cpp
Comment thread test/js/node/vm/vm.test.ts Outdated
Comment thread test/js/node/vm/vm.test.ts Outdated
Comment thread src/jsc/bindings/NodeVMSourceTextModule.cpp Outdated
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in f28e900:

  • NodeVMSourceTextModule::cachedData() now raises "createCachedData failed" instead of dereferencing a null RefPtr when getBytecode() fails without throwing (pre-existing on the line this PR touched).
  • The corruption test asserts the combined { stdout, stderr, exitCode, signalCode } of the child so a regression shows the crash output in the failure diff.

Two suggestions were not taken, with reasoning in the threads: the header is an integrity check rather than an authentication boundary (the same contract V8 uses for cachedData, and it must stay a public deterministic function of the bytes to keep cross-process reuse working), and createCachedDataBuffer() only returns null with a pending exception, which the RETURN_IF_EXCEPTION after each call already handles.

test/js/node/vm/vm.test.ts and the Node vm cachedData tests pass locally with the debug (ASAN) build.

@coderabbitai coderabbitai 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.

♻️ Duplicate comments (1)
src/jsc/bindings/NodeVMSourceTextModule.cpp (1)

514-520: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Buffer null check from the prior review is still missing.

The new guard handles cachedBytecode == nullptr, but createCachedDataBuffer() can also return nullptr (its [[unlikely]] allocation-failure branch) without throwing. Line 520 then caches a null buffer into m_cachedBytecodeBuffer and line 523 returns it. Guard the buffer too, matching NodeVM::createCachedData.

Proposed fix
         JSUint8Array* buffer = createCachedDataBuffer(globalObject, cachedBytecode->span());
         RETURN_IF_EXCEPTION(scope, nullptr);
+        if (!buffer) [[unlikely]] {
+            throwVMError(globalObject, scope, "createCachedData failed"_s);
+            return nullptr;
+        }
         m_cachedBytecodeBuffer.set(vm, this, buffer);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/NodeVMSourceTextModule.cpp` around lines 514 - 520, The
cached bytecode path in NodeVMSourceTextModule::createCachedData still misses
the null check for the JSUint8Array returned by createCachedDataBuffer(). Add a
guard after creating the buffer, before m_cachedBytecodeBuffer.set, and mirror
the failure handling used in NodeVM::createCachedData by throwing a VM error and
returning nullptr if the buffer allocation fails. Keep the existing
cachedBytecode null handling unchanged and ensure the returned value is only
used when the buffer is valid.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/jsc/bindings/NodeVMSourceTextModule.cpp`:
- Around line 514-520: The cached bytecode path in
NodeVMSourceTextModule::createCachedData still misses the null check for the
JSUint8Array returned by createCachedDataBuffer(). Add a guard after creating
the buffer, before m_cachedBytecodeBuffer.set, and mirror the failure handling
used in NodeVM::createCachedData by throwing a VM error and returning nullptr if
the buffer allocation fails. Keep the existing cachedBytecode null handling
unchanged and ensure the returned value is only used when the buffer is valid.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e647e475-19d9-4c2b-bdb1-d71613b12109

📥 Commits

Reviewing files that changed from the base of the PR and between aa2e6e7 and f28e900.

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

Comment thread src/jsc/bindings/NodeVMScript.cpp Outdated
Comment thread src/jsc/bindings/NodeVMScript.cpp Outdated

@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.

Thanks — 00f190c addresses my last note (cachedDataProduced now reports false when production fails, and the test locks it in), so I have nothing further. Deferring to a human for the final sign-off since this introduces a new cachedData wire format and changes the bytecode payload's memory ownership in core JSC bindings.

Extended reasoning...

Overview

This PR hardens node:vm's cachedData handling so that corrupted, truncated, or foreign buffers are rejected (cachedDataRejected = true / ERR_VM_MODULE_CACHED_DATA_REJECTED) instead of crashing inside JSC's bytecode decoder. It does so by wrapping serialized bytecode in a 16-byte { magic, version, XXH3-64 } header on the write side (createCachedDataBuffer) and verifying it on the read side (unwrapCachedData) before calling decodeCodeBlock. The verified payload is copied into a MallocSpan<uint8_t, JSC::VMMalloc> owned by the CachedBytecode (since JSC retains the decoder for lazy function-body decoding). All three entry points — vm.Script, vm.compileFunction, vm.SourceTextModule — are updated, along with their producers. Over the review cycle the PR also picked up fixes for three pre-existing null-deref sites and a stale cachedDataProduced(true) override, each with test coverage.

Files touched: src/jsc/bindings/NodeVM.{h,cpp}, NodeVMScript.cpp, NodeVMSourceTextModule.cpp, test/js/node/vm/vm.test.ts.

Security risks

The change is strictly a hardening: it gates an unsafe decoder behind an integrity check that previously did not exist. The "forgeable checksum" objection was raised and correctly dismissed in-thread — cachedData is an integrity/compatibility contract (matching V8's design), not an authentication boundary, and any caller who can craft a header is already executing arbitrary JS in-process. The payload copy removes a potential use-after-free where JSC's lazily-decoded functions could outlive the caller's buffer. I see no new exposure introduced.

Level of scrutiny

Moderate-to-high. This is ~150 lines of native C++ in core JSC bindings that (a) defines a new persisted binary format, (b) changes memory-ownership semantics for decoded bytecode, and (c) threads through three separate code paths. None of it is mechanical. The implementation looks correct and is well-tested (corruption matrix across all three entry points in a child process, plus the existing Node test-vm-cached-data.js cross-process round-trip), but the format and ownership decisions are the kind of thing a maintainer should ratify rather than a bot.

Other factors

The author has been responsive across four review rounds; every concern I and CodeRabbit raised has been addressed or rebutted with a sound argument, and all threads are resolved. The bug-hunting system found nothing on the latest revision. CI build #65446 is in flight for the head commit. Given the scope (native bindings, new wire format, lifetime change), I'm deferring rather than auto-approving.

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status at 2c7c0aa: merged main through 8cc0397, which includes the WebKit bump in #43616. There was no conflict. The bump changed the bytecode decoder and the payload layout under this PR, so everything below was run again on the merge. CI is running on it.

What the bump changed for this PR:

How to reproduce, one process per offset (the script is in the PR description under Notes): flip one byte of a genuine createCachedData() blob and pass it to new vm.Script(src, { cachedData }).

  • Main's src/ on this tree, debug build with ASAN, Malloc=1, 888 offsets: 262 die, 590 are accepted and run the damaged bytecode, 36 are rejected.
  • This branch, same build and harness: 912 of 912 flips are rejected with the right result. Across new vm.Script, vm.compileFunction and new vm.SourceTextModule: 2464 of 2464 flips and 616 of 616 truncations rejected, every process exits 0 with an empty stderr, and the three intact blobs are accepted.

Local verification on the merge, debug build with ASAN: test/js/node/vm/vm.test.ts 303 pass and 0 fail. test/bundler/bundler_bytecode_portable.test.ts 22 pass and 0 fail against main's updated snapshot, with no snapshot change from this PR. Node's test-vm-cached-data.js, test-vm-createcacheddata.js, test-vm-module-cached-data.js and test-vm-basic.js pass. The PR's test still fails against main's src/ (AssertionError: flip@64, false !== true).

The PR description is rewritten for the current state of main. It also records how Node behaves: Node rejects truncated and stale blobs, and by default it does not verify its checksum, so a flipped byte crashes Node too.

oven-sh/WebKit#368 (bounds checks inside the decoder) is closed. It went against oven-sh/WebKit#677, and bounds checks do not catch a changed byte that stays in range.

All review threads are resolved.

Comment thread src/jsc/bindings/NodeVMScript.cpp Outdated
Comment thread src/jsc/bindings/NodeVM.cpp Outdated
Comment thread src/jsc/bindings/NodeVM.cpp Outdated
Comment thread src/jsc/bindings/NodeVM.h Outdated
Comment thread src/jsc/bindings/NodeVM.h Outdated
Comment thread src/jsc/bindings/NodeVMSourceTextModule.cpp Outdated
Comment thread src/jsc/bindings/NodeVM.cpp Outdated
Comment thread src/jsc/bindings/NodeVM.h Outdated
Comment thread src/jsc/bindings/NodeVM.h Outdated
Comment thread test/js/node/vm/vm.test.ts

@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.

No issues found this pass, and all earlier findings are addressed — but this is memory-safety C++ that introduces a new on-disk cachedData format, so it's worth a human sign-off on the header design and the owned-copy lifetime.

What was reviewed:

  • Header validation in unwrapCachedData — every field checked before decodeCodeBlock sees the payload; memcpy avoids alignment UB; length is compared against the actual span, not trusted.
  • The payload copy into MallocSpan<uint8_t, VMMalloc> so JSC's lazy decoder does not borrow the caller's buffer.
  • All three consumers (vm.Script, compileFunction, SourceTextModule) route through the same wrap/unwrap pair; the produce side symmetrically prepends the header at every site.
  • The getBytecodeBuffer() / cachedData() null guards and the removed cachedDataProduced(true) override — now defensive-only after eager checkSyntax from #31827 closed the known repro.
Extended reasoning...

Overview

The PR wraps JSC's serialized bytecode in a 24-byte integrity header (magic, payloadLength, sourceHash, jscVersion, XXH3-64 payloadHash) so node:vm's three cachedData consumers can reject corrupted/truncated/mismatched buffers before handing them to JSC::decodeCodeBlock, which otherwise dereferences payload-relative offsets without bounds checks and segfaults. It also copies the accepted payload into memory owned by the CachedBytecode (JSC keeps the decoder alive for lazily decoded function bodies), and picks up three same-class defensive fixes on the produce side (getBytecodeBuffer null guard, SourceTextModule::cachedData null guard, dropped unconditional cachedDataProduced(true) override plus its now-dead setter).

Security risks

The input is user-controlled bytes flowing into a bytecode decoder — the fix is strictly a hardening in that direction. The header is an integrity check, not authentication (matching V8's contract for cachedData); a caller who can forge the header can also just supply matching bytecode, which is the documented capability. I did not find a way for the new code to make things worse: validation happens before allocation of the CachedBytecode, size arithmetic is on size_t spans with no user-multiplied quantities, and payload.size() != header.payloadLength rejects both truncation and extension.

Level of scrutiny

High. This is C++ in the JSC bindings, on a path that previously crashed the process from plain JS, and it defines a persistent binary format that becomes part of the user-visible createCachedData() contract. That combination — untrusted-input handling plus a new wire format — is exactly the kind of change a maintainer should eyeball, particularly the choice of header fields (e.g. whether source.hash() and computeJSCBytecodeCacheVersion() are the right staleness keys) and the VMMalloc allocator for the owned copy.

Other factors

Over several review rounds every finding was addressed: the module-path null-deref, the third sibling in getBytecodeBuffer, the cachedDataProduced flag inconsistency, the dead setter, and the comment-cop flags. CI was green across all completed lanes at 9ed4358 per the author's status comment. The corruption-matrix test spawns a child and asserts {stdout, stderr, exitCode, signalCode}, so a regression to the old crash would fail loudly. Deferring rather than approving because the format decision and the JSC lifetime reasoning deserve a maintainer's eyes, not because anything looks wrong.

@robobun
robobun force-pushed the farm/4b1d3dba/node-vm-cacheddata-validate branch from a8baf5e to cae82eb Compare July 28, 2026 20:59
Comment thread src/jsc/bindings/NodeVMScript.cpp
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/vm/vm.test.ts`:
- Around line 906-924: Update corruptions() to include explicit empty and
zero-filled cached-data variants using Buffer.alloc(0) and
Buffer.alloc(good.length), alongside the existing cases, so both boundary
representations are passed through rejection validation before header decoding.
- Around line 945-970: Extend the cached-data tests in the compileFunction and
SourceTextModule blocks to create cached data from a distinct source, then
supply it to the target source. Assert compileFunction rejects the mismatched
data and still executes normally, while SourceTextModule throws
ERR_VM_MODULE_CACHED_DATA_REJECTED; retain the existing corruption and
matching-data checks.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a4eb3345-9772-4223-9fb9-6348c07f9cf9

📥 Commits

Reviewing files that changed from the base of the PR and between e532ad9 and 13058c3.

📒 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
💤 Files with no reviewable changes (1)
  • src/jsc/bindings/NodeVMScript.h

Comment thread test/js/node/vm/vm.test.ts
Comment thread test/js/node/vm/vm.test.ts
Comment thread src/jsc/bindings/NodeVMScript.cpp

@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.

I reviewed this PR again after 8507750 and didn't find any bugs. Because it changes memory-safety C++ in the JSC bindings on a security-relevant path (user-controlled bytes reaching decodeCodeBlock), a human look would still be worthwhile.

What was reviewed:

  • unwrapCachedData: header is read via memcpy after a size check, every field is compared before the payload copy, and the accepted payload is copied into an owned MallocSpan so compileFunction/SourceTextModule no longer hand JSC a span into a stack Vector.
  • The provided-vs-empty gate in all three consumers now keys on presence (cachedDataProvided / !isUndefined()); validateCachedData returns true for a zero-length view, so Buffer.alloc(0) reaches the header check and is rejected.
  • createCachedDataBuffer writes header+payload into a fresh Uint8Array; the payloadLength narrow to uint32_t is fine for realistic bytecode sizes.
  • The subprocess test asserts signalCode === null and covers the full corruption matrix (including empty/zeros and cross-source) across Script, compileFunction, and SourceTextModule.
Extended reasoning...

Overview

The PR adds a 24-byte integrity header (magic, payload length, source hash, JSC bytecode-cache version, XXH3-64 payload hash) to the buffers createCachedData()/produceCachedData emit, and a matching unwrapCachedData() that verifies every field before JSC::decodeCodeBlock sees the payload. All three cachedData consumers — new vm.Script, vm.compileFunction, new vm.SourceTextModule — route through it, and the accepted payload is copied into memory owned by the CachedBytecode instead of borrowed. The latest commit (8507750) switches the consumers from gating on !cachedData.isEmpty() to gating on whether the option was provided, so an explicitly-passed empty buffer is rejected like Node does, and extends the test matrix with empty/zero-fill variants and cross-source rejection for compileFunction/SourceTextModule.

Security risks

This is exactly the security-relevant surface: user-controlled bytes previously flowed straight into JSC's bytecode decoder, which derives pointers from self-relative offsets in the payload with no bounds check — truncation produced wild heap reads and a SIGSEGV. The fix is the right shape (validate-before-decode with a checksum, mirroring V8's SerializedCodeData header), and the validated payload is copied so the decoder's lazy function-body decoding can't outlive the caller's buffer. I did not find a bypass: length is an exact match (catches both truncation and extension), and the XXH3-64 payload hash catches bit flips that leave length intact. The header is read via memcpy after a size < sizeof(CachedDataHeader) guard, so there's no unaligned or short read.

Level of scrutiny

High. This is hand-written C++ in src/jsc/bindings/, on a path where the pre-fix behavior was a native crash from user input, and it changes ownership of a buffer that JSC retains for lazy decoding. REVIEW.md's memory-safety and untrusted-input sections apply directly. The change is well-scoped and has been through ten iterations with every prior review finding addressed, but it is not the kind of mechanical change I'd approve without a human look.

Other factors

All prior inline findings from this reviewer and CodeRabbit are marked resolved and I verified the fixes in the current diff: the redundant m_options.cachedData retention is released after unwrapCachedData copies (cachedData = {}); the dead cachedDataProduced(true) setter was removed; the getBytecodeBuffer() null guard is defensive-only after #31827 (no deterministic fixture exists). The test follows harness conventions — subprocess with concurrent pipe drain, combined {stdout, stderr, exitCode, signalCode} assertion, deterministic corruption seeding — and the PR description's evidence block shows it fails on main (SIGSEGV/assertion) and passes on the branch under both ASAN debug and release.

@robobun
robobun force-pushed the farm/4b1d3dba/node-vm-cacheddata-validate branch from 8507750 to 9d6db6c Compare August 28, 2026 00:41

@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.

I re-reviewed after the latest push and found no new issues — every point raised in earlier rounds (stderr in the test assertion, the three null-CachedBytecode deref sites, the stale cachedDataProduced override and dead setter, the retained m_options.cachedData copy, and empty-buffer-treated-as-absent) is now addressed in the squashed commit. Given this is C++ memory-safety hardening on an adversarial-input path into JSC's decoder and changes the on-disk cachedData format, a maintainer sign-off is still worthwhile.

What was reviewed

  • unwrapCachedData: header fields checked in fail-closed order before any payload deref; MallocSpan copy is owned by the returned CachedBytecode, and the source Vector is cleared at the one call site that retained it.
  • All three consumers (vm.Script, vm.compileFunction, vm.SourceTextModule) now gate on presence (cachedDataProvided / !isUndefined()) rather than !isEmpty(), so Buffer.alloc(0) is rejected — the test's ["empty", …] variant covers it.
  • createCachedDataBuffer: header + payload written into a single createUninitializedBuffer allocation; every producer (createCachedData, getBytecodeBuffer, NodeVMSourceTextModule::cachedData, compileFunction produce path) routes through it.
Extended reasoning...

Overview

The PR wraps node:vm cachedData in a 24-byte integrity envelope (magic / payloadLength / sourceHash / jscVersion / xxhash3-64 payloadHash) so that corrupted, truncated, extended, or mismatched buffers are rejected before reaching JSC::decodeCodeBlock, which does no bounds checking and previously crashed on truncated input. It touches NodeVM.cpp/.h (the shared createCachedDataBuffer/unwrapCachedData helpers and compileFunction path), NodeVMScript.cpp/.h (vm.Script construct/produce paths, dead setter removed), NodeVMSourceTextModule.cpp (module construct + createCachedData null guard), and adds a subprocess-based corruption-matrix test to test/js/node/vm/vm.test.ts. Since my last inline comment, a cachedDataProvided flag was added so a provided-but-empty buffer is rejected rather than treated as absent, and the test gained an ["empty", Buffer.alloc(0)] variant.

Security risks

This is a memory-safety hardening on an adversarial-input path: user-supplied bytes previously flowed straight into JSC's bytecode decoder, whose self-relative-offset format enabled wild heap reads on truncated input. The envelope check is fail-closed — every mismatch returns nullptr before any payload byte influences a pointer — and the accepted payload is copied into a MallocSpan owned by the CachedBytecode so lazy decoding cannot dangle after the caller's buffer is freed or mutated. payloadLength is compared against the actual remaining span rather than trusted, and the xxhash covers only the payload the length check already bounded. I did not find a bypass, but xxhash3 is a non-cryptographic checksum (integrity, not authentication) and the decoder itself remains unhardened until the referenced WebKit-side change lands, so the residual risk is a maintainer judgment call.

Level of scrutiny

High. This is hand-written C++ in the JSC bindings, the "most-blocked category" per REVIEW.md, on a path that consumes attacker-controllable bytes and hands them to a decoder that trusts its input. It also changes user-visible API surface: the cachedData blob format gains a 24-byte header, so blobs are no longer interchangeable with other consumers of raw JSC bytecode (acceptable per the PR's reasoning — Node's contract is same-runtime-version only — but a format decision a maintainer should ratify). The Node/Web-compat and API-design sections of .claude/docs/landing-prs.md both apply.

Other factors

This PR has been through many review iterations; every concrete issue I raised across prior rounds is verifiably fixed in the current diff (stderr/signalCode surfaced in the test assertion; null-RefPtr<CachedBytecode> guarded at all three producer sites; cachedDataProduced(true) override removed and its now-dead setter deleted; m_options.cachedData cleared after the owned copy is taken; empty-buffer routed through the rejection path with test coverage). The bug-hunt exit reason was dry_streak with no findings. No CODEOWNERS entry covers these paths and there are no outstanding CHANGES_REQUESTED reviews. Several github-actions[bot] inline threads were self-resolved by the author — I could not read their content, which is one more reason a human pass is worthwhile before merge.

@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.

Code review found no issues

No high-confidence issues detected in this change.

cachedData was passed straight to JSC::decodeCodeBlock, which follows
offsets stored inside the buffer and is only safe on an intact copy of
its own serializer's output. A corrupted or truncated buffer crashed the
process (or was silently accepted), where Node sets cachedDataRejected
and recompiles.

createCachedData() and produceCachedData now prepend a 24-byte
{ magic, payloadLength, sourceHash, jscVersion, payloadHash } header,
and new vm.Script, vm.compileFunction, and new vm.SourceTextModule
verify every field before decoding. Anything that does not match takes
the existing rejection path. The accepted payload is copied into memory
owned by the CachedBytecode because decoded functions retain the decoder
for lazy code block decoding, and the input Vector is released once the
copy exists.

A provided-but-empty cachedData buffer is now rejected like Node does,
instead of being treated as absent. NodeVMSourceTextModule::cachedData()
surfaces a serialization failure as an error instead of dereferencing a
null RefPtr.
…portability test

The portability snapshot masks JSC's cacheVersion and headerChecksum at
offsets 0 and 8 so it only moves when the serialized bytes do. vm
cachedData now carries a 24-byte integrity header in front of that
payload, which shifted the masked fields and added version-dependent
fields of its own. Strip the header for the vm entries; the existing
snapshot hashes match unchanged, which confirms the payload bytes are
identical to before.
@robobun
robobun force-pushed the farm/4b1d3dba/node-vm-cacheddata-validate branch from b15b7f5 to d9b4996 Compare September 8, 2026 00:43

@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.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

The three CachedBytecode::create(std::span(...), nullptr, {}) lines this PR changes are also a heap-use-after-free on their own, separate from the envelope validation. On an ASAN build with Malloc=1:

const vm = require("node:vm");
const src = "return a + 1234;";
const a = vm.compileFunction(src, ["a"], { produceCachedData: true });
const b = vm.compileFunction(src, ["a"], { cachedData: a.cachedData });
console.log(b(1));

heap-use-after-free, READ of size 4 in JSC::VariableLengthObject<JSC::UnlinkedFunctionCodeBlock*>::isEmpty() (CachedTypes.cpp:1203), freed by WTF::VectorBuffer<unsigned char>::~VectorBuffer. The blob is accepted and intact. Each UnlinkedFunctionExecutable keeps the Decoder and decodes its body on the first call, after the options WTF::Vector is gone.

The test added here does not show that. Its vm.compileFunction("return a + 1234;", ["a"], { cachedData: good }) case asserts === 1235, and an unfixed build prints 1235 as well. A normal build reads the freed bytes through bmalloc and reports nothing.

I opened #42229 with the lifetime half on its own: the same MallocSpan copy behind one helper, no format change, and an ASAN-gated test that spawns with Malloc=1 and fails when built against main's src/. If it lands first, the rebase here is to keep the createOwnedCachedBytecode call at those three lines and drop the copy from unwrapCachedData.

…-cacheddata-validate

# Conflicts:
#	src/jsc/bindings/NodeVM.cpp
#	src/jsc/bindings/NodeVM.h
#	src/jsc/bindings/NodeVMScript.cpp
#	src/jsc/bindings/NodeVMSourceTextModule.cpp

@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.

Code review found no issues

No high-confidence issues detected in this change.

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