diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 4af8172edc49..ffb1e2ceeaed 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -38,6 +38,10 @@ #include "JavaScriptCore/JSGlobalProxyInlines.h" #include "GCDefferalContext.h" #include "JSBuffer.h" +#include "xxhash3.h" + +#include +#include #include #include @@ -125,6 +129,77 @@ bool extractCachedData(JSValue cachedDataValue, WTF::Vector& outCachedD return false; } +// Integrity header verified by unwrapCachedData before decodeCodeBlock sees the payload; same role as V8's SerializedCodeData header. +struct CachedDataHeader { + uint32_t magic; + uint32_t payloadLength; + uint32_t sourceHash; + uint32_t jscVersion; + uint64_t payloadHash; +}; +static_assert(sizeof(CachedDataHeader) == 24, "header layout is part of the cachedData format"); + +static constexpr uint32_t cachedDataMagic = 0x436E7542; // "BunC" + +static uint64_t hashCachedDataPayload(std::span payload) +{ + return highway_xxhash3_64(payload.data(), payload.size(), 0); +} + +JSC::JSUint8Array* createCachedDataBuffer(JSGlobalObject* globalObject, const JSC::SourceCode& source, std::span bytecode) +{ + JSC::JSUint8Array* buffer = WebCore::createUninitializedBuffer(globalObject, sizeof(CachedDataHeader) + bytecode.size()); + if (!buffer) [[unlikely]] { + return nullptr; + } + + const CachedDataHeader header { + .magic = cachedDataMagic, + .payloadLength = static_cast(bytecode.size()), + .sourceHash = source.hash(), + .jscVersion = JSC::computeJSCBytecodeCacheVersion(), + .payloadHash = hashCachedDataPayload(bytecode), + }; + uint8_t* data = buffer->typedVector(); + memcpy(data, &header, sizeof(header)); + if (!bytecode.empty()) { + memcpy(data + sizeof(header), bytecode.data(), bytecode.size()); + } + return buffer; +} + +RefPtr unwrapCachedData(const JSC::SourceCode& source, std::span cachedData) +{ + if (cachedData.size() < sizeof(CachedDataHeader)) { + return nullptr; + } + + CachedDataHeader header; + memcpy(&header, cachedData.data(), sizeof(header)); + if (header.magic != cachedDataMagic) { + return nullptr; + } + + std::span payload = cachedData.subspan(sizeof(CachedDataHeader)); + if (payload.size() != header.payloadLength) { + return nullptr; + } + if (header.sourceHash != source.hash()) { + return nullptr; + } + if (header.jscVersion != JSC::computeJSCBytecodeCacheVersion()) { + return nullptr; + } + if (header.payloadHash != hashCachedDataPayload(payload)) { + return nullptr; + } + + // Copy: decoded functions retain the Decoder for lazy code block decoding, so a borrowed span would dangle. + auto payloadCopy = WTF::MallocSpan::malloc(payload.size()); + memcpySpan(payloadCopy.mutableSpan(), payload); + return JSC::CachedBytecode::create(WTF::move(payloadCopy), {}); +} + JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, const ArgList& args, const SourceOrigin& sourceOrigin, CompileFunctionOptions&& options, JSC::SourceTaintedOrigin sourceTaintOrigin, JSC::JSScope* scope) { ASSERT(scope); @@ -209,10 +284,13 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c TriState bytecodeAccepted = TriState::Indeterminate; - if (!options.cachedData.isEmpty()) { - cachedBytecode = CachedBytecode::create(std::span(options.cachedData), nullptr, {}); - SourceCodeKey key(sourceCode, {}, JSC::SourceCodeType::ProgramType, lexicallyScopedFeatures, JSC::JSParserScriptMode::Classic, JSC::DerivedContextType::None, JSC::EvalContextType::None, false, {}, std::nullopt); - unlinkedProgramCodeBlock = JSC::decodeCodeBlock(vm, key, *cachedBytecode); + // Node treats a provided-but-empty cachedData buffer as rejected, not absent. + if (options.cachedDataProvided) { + cachedBytecode = unwrapCachedData(sourceCode, std::span(options.cachedData)); + if (cachedBytecode) { + SourceCodeKey key(sourceCode, {}, JSC::SourceCodeType::ProgramType, lexicallyScopedFeatures, JSC::JSParserScriptMode::Classic, JSC::DerivedContextType::None, JSC::EvalContextType::None, false, {}, std::nullopt); + unlinkedProgramCodeBlock = JSC::decodeCodeBlock(vm, key, *cachedBytecode); + } if (unlinkedProgramCodeBlock == nullptr) { bytecodeAccepted = TriState::False; } else { @@ -253,7 +331,7 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c if (options.produceCachedData) { RefPtr producedBytecode = getBytecode(globalObject, programExecutable, sourceCode); if (producedBytecode) { - JSC::JSUint8Array* buffer = WebCore::createBuffer(globalObject, producedBytecode->span()); + JSC::JSUint8Array* buffer = createCachedDataBuffer(globalObject, sourceCode, producedBytecode->span()); RETURN_IF_EXCEPTION(throwScope, nullptr); function->putDirect(vm, JSC::Identifier::fromString(vm, "cachedData"_s), buffer); function->putDirect(vm, JSC::Identifier::fromString(vm, "cachedDataProduced"_s), jsBoolean(true)); @@ -476,8 +554,7 @@ JSC::EncodedJSValue createCachedData(JSGlobalObject* globalObject, const JSC::So return throwVMError(globalObject, scope, "createCachedData failed"_s); } - std::span bytes = bytecode->span(); - JSC::JSUint8Array* buffer = WebCore::createBuffer(globalObject, bytes); + JSC::JSUint8Array* buffer = createCachedDataBuffer(globalObject, source, bytecode->span()); RETURN_IF_EXCEPTION(scope, {}); if (!buffer) { @@ -2091,6 +2168,7 @@ bool CompileFunctionOptions::fromJS(JSC::JSGlobalObject* globalObject, JSC::VM& if (validateCachedData(globalObject, vm, scope, options, this->cachedData)) { RETURN_IF_EXCEPTION(scope, false); + this->cachedDataProvided = true; any = true; } diff --git a/src/jsc/bindings/NodeVM.h b/src/jsc/bindings/NodeVM.h index fc9b555552ed..16c56a85d738 100644 --- a/src/jsc/bindings/NodeVM.h +++ b/src/jsc/bindings/NodeVM.h @@ -27,6 +27,8 @@ namespace NodeVM { RefPtr getBytecode(JSGlobalObject* globalObject, JSC::ProgramExecutable* executable, const JSC::SourceCode& source); RefPtr getBytecode(JSGlobalObject* globalObject, JSC::ModuleProgramExecutable* executable, const JSC::SourceCode& source); bool extractCachedData(JSValue cachedDataValue, WTF::Vector& outCachedData); +JSC::JSUint8Array* createCachedDataBuffer(JSGlobalObject* globalObject, const JSC::SourceCode& source, std::span bytecode); +RefPtr unwrapCachedData(const JSC::SourceCode& source, std::span cachedData); String stringifyAnonymousFunction(JSGlobalObject* globalObject, const ArgList& args, ThrowScope& scope, int* outOffset); JSC::EncodedJSValue createCachedData(JSGlobalObject* globalObject, const JSC::SourceCode& source); bool handleException(JSGlobalObject* globalObject, VM& vm, NakedPtr exception, ThrowScope& throwScope); @@ -69,6 +71,7 @@ class CompileFunctionOptions : public BaseVMOptions { JSGlobalObject* parsingContext = nullptr; JSValue contextExtensions {}; bool produceCachedData = false; + bool cachedDataProvided = false; using BaseVMOptions::BaseVMOptions; diff --git a/src/jsc/bindings/NodeVMScript.cpp b/src/jsc/bindings/NodeVMScript.cpp index 8ab522bb0711..b95bc54695d5 100644 --- a/src/jsc/bindings/NodeVMScript.cpp +++ b/src/jsc/bindings/NodeVMScript.cpp @@ -63,6 +63,7 @@ bool ScriptOptions::fromJS(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC:: if (validateCachedData(globalObject, vm, scope, options, this->cachedData)) { RETURN_IF_EXCEPTION(scope, false); + this->cachedDataProvided = true; any = true; } @@ -157,6 +158,8 @@ constructScript(JSGlobalObject* globalObject, CallFrame* callFrame, JSValue newT } const bool produceCachedData = options.produceCachedData; + // Node treats a provided-but-empty cachedData buffer as rejected, not absent. + const bool cachedDataProvided = options.cachedDataProvided; auto filename = options.filename; NodeVMScript* script = NodeVMScript::create(vm, globalObject, structure, WTF::move(source), WTF::move(options)); @@ -166,17 +169,19 @@ constructScript(JSGlobalObject* globalObject, CallFrame* callFrame, JSValue newT WTF::Vector& cachedData = script->cachedData(); - if (!cachedData.isEmpty()) { + if (cachedDataProvided) { JSC::ProgramExecutable* executable = script->cachedExecutable(); if (!executable) { executable = script->createExecutable(); } ASSERT(executable); - JSC::LexicallyScopedFeatures lexicallyScopedFeatures = globalObject->globalScopeExtension() ? JSC::TaintedByWithScopeLexicallyScopedFeature : JSC::NoLexicallyScopedFeatures; - JSC::SourceCodeKey key(script->source(), {}, JSC::SourceCodeType::ProgramType, lexicallyScopedFeatures, JSC::JSParserScriptMode::Classic, JSC::DerivedContextType::None, JSC::EvalContextType::None, false, {}, std::nullopt); - Ref cachedBytecode = JSC::CachedBytecode::create(std::span(cachedData), nullptr, {}); - JSC::UnlinkedProgramCodeBlock* unlinkedBlock = JSC::decodeCodeBlock(vm, key, WTF::move(cachedBytecode)); + JSC::UnlinkedProgramCodeBlock* unlinkedBlock = nullptr; + if (RefPtr cachedBytecode = unwrapCachedData(script->source(), std::span(cachedData))) { + JSC::LexicallyScopedFeatures lexicallyScopedFeatures = globalObject->globalScopeExtension() ? JSC::TaintedByWithScopeLexicallyScopedFeature : JSC::NoLexicallyScopedFeatures; + JSC::SourceCodeKey key(script->source(), {}, JSC::SourceCodeType::ProgramType, lexicallyScopedFeatures, JSC::JSParserScriptMode::Classic, JSC::DerivedContextType::None, JSC::EvalContextType::None, false, {}, std::nullopt); + unlinkedBlock = JSC::decodeCodeBlock(vm, key, cachedBytecode.releaseNonNull()); + } if (!unlinkedBlock) { script->cachedDataRejected(TriState::True); @@ -197,10 +202,10 @@ constructScript(JSGlobalObject* globalObject, CallFrame* callFrame, JSValue newT script->cachedDataRejected(TriState::True); } } + // unwrapCachedData owns its copy; nothing reads m_options.cachedData after this. + cachedData = {}; } else if (produceCachedData) { script->cacheBytecode(); - // TODO(@heimskr): is there ever a case where bytecode production fails? - script->cachedDataProduced(true); } return JSValue::encode(script); @@ -244,10 +249,11 @@ JSC::JSUint8Array* NodeVMScript::getBytecodeBuffer() cacheBytecode(); } - ASSERT(m_cachedBytecode); + if (!m_cachedBytecode) [[unlikely]] { + return nullptr; + } - std::span bytes = m_cachedBytecode->span(); - m_cachedBytecodeBuffer.set(vm(), this, WebCore::createBuffer(globalObject(), bytes)); + m_cachedBytecodeBuffer.set(vm(), this, createCachedDataBuffer(globalObject(), m_source, m_cachedBytecode->span())); if (!m_cachedBytecodeBuffer) { return nullptr; } diff --git a/src/jsc/bindings/NodeVMScript.h b/src/jsc/bindings/NodeVMScript.h index 35d3a15bfcbf..1d339fb456a6 100644 --- a/src/jsc/bindings/NodeVMScript.h +++ b/src/jsc/bindings/NodeVMScript.h @@ -11,6 +11,7 @@ class ScriptOptions : public BaseVMOptions { WTF::Vector cachedData; std::optional timeout = std::nullopt; bool produceCachedData = false; + bool cachedDataProvided = false; using BaseVMOptions::BaseVMOptions; @@ -74,7 +75,6 @@ class NodeVMScript final : public JSC::JSDestructibleObject, public SigintReceiv RefPtr cachedBytecode() const { return m_cachedBytecode; } JSC::ProgramExecutable* cachedExecutable() const { return m_cachedExecutable.get(); } bool cachedDataProduced() const { return m_cachedDataProduced; } - void cachedDataProduced(bool value) { m_cachedDataProduced = value; } TriState cachedDataRejected() const { return m_cachedDataRejected; } void cachedDataRejected(TriState value) { m_cachedDataRejected = value; } bool sourceMapURLParsed() const { return m_sourceMapURLParsed; } diff --git a/src/jsc/bindings/NodeVMSourceTextModule.cpp b/src/jsc/bindings/NodeVMSourceTextModule.cpp index 67b09a97e8a9..803d55370e79 100644 --- a/src/jsc/bindings/NodeVMSourceTextModule.cpp +++ b/src/jsc/bindings/NodeVMSourceTextModule.cpp @@ -112,7 +112,8 @@ NodeVMSourceTextModule* NodeVMSourceTextModule::create(VM& vm, JSGlobalObject* g RETURN_IF_EXCEPTION(scope, nullptr); ptr->finishCreation(vm); - if (cachedData.isEmpty()) { + // Node treats a provided-but-empty cachedData buffer as rejected, not absent. + if (cachedDataValue.isUndefined()) { return ptr; } @@ -124,12 +125,13 @@ NodeVMSourceTextModule* NodeVMSourceTextModule::create(VM& vm, JSGlobalObject* g } ptr->m_cachedExecutable.set(vm, ptr, executable); - LexicallyScopedFeatures lexicallyScopedFeatures = globalObject->globalScopeExtension() ? TaintedByWithScopeLexicallyScopedFeature : NoLexicallyScopedFeatures; - SourceCodeKey key(ptr->sourceCode(), {}, SourceCodeType::ProgramType, lexicallyScopedFeatures, JSParserScriptMode::Classic, DerivedContextType::None, EvalContextType::None, false, {}, std::nullopt); - Ref cachedBytecode = CachedBytecode::create(std::span(cachedData), nullptr, {}); - RETURN_IF_EXCEPTION(scope, nullptr); - UnlinkedModuleProgramCodeBlock* unlinkedBlock = decodeCodeBlock(vm, key, WTF::move(cachedBytecode)); - RETURN_IF_EXCEPTION(scope, nullptr); + UnlinkedModuleProgramCodeBlock* unlinkedBlock = nullptr; + if (RefPtr cachedBytecode = unwrapCachedData(ptr->sourceCode(), std::span(cachedData))) { + LexicallyScopedFeatures lexicallyScopedFeatures = globalObject->globalScopeExtension() ? TaintedByWithScopeLexicallyScopedFeature : NoLexicallyScopedFeatures; + SourceCodeKey key(ptr->sourceCode(), {}, SourceCodeType::ProgramType, lexicallyScopedFeatures, JSParserScriptMode::Classic, DerivedContextType::None, EvalContextType::None, false, {}, std::nullopt); + unlinkedBlock = decodeCodeBlock(vm, key, cachedBytecode.releaseNonNull()); + RETURN_IF_EXCEPTION(scope, nullptr); + } if (unlinkedBlock) { JSScope* jsScope = globalObject->globalScope(); @@ -516,8 +518,12 @@ JSUint8Array* NodeVMSourceTextModule::cachedData(JSGlobalObject* globalObject) if (!m_cachedBytecodeBuffer) { RefPtr cachedBytecode = bytecode(globalObject); RETURN_IF_EXCEPTION(scope, nullptr); - std::span bytes = cachedBytecode->span(); - JSUint8Array* buffer = WebCore::createBuffer(globalObject, bytes); + // getBytecode can return null without throwing (serialization failure). + if (!cachedBytecode) [[unlikely]] { + throwVMError(globalObject, scope, "createCachedData failed"_s); + return nullptr; + } + JSUint8Array* buffer = createCachedDataBuffer(globalObject, m_sourceCode, cachedBytecode->span()); RETURN_IF_EXCEPTION(scope, nullptr); m_cachedBytecodeBuffer.set(vm, this, buffer); } diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index c4ac9cf7b07b..cf2113e95313 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -892,6 +892,108 @@ test("can't use bytecode from a different script", () => { expect(secondScript.runInThisContext()).toBe(4); }); +test("rejects corrupted cachedData instead of crashing", async () => { + // Corrupting, truncating, or extending createCachedData() output must set + // cachedDataRejected (or throw ERR_VM_MODULE_CACHED_DATA_REJECTED for modules) + // and recompile from source, like Node. Runs in a child process since the + // broken behavior is a process crash: a truncated tail leaves embedded + // self-relative offsets pointing past the copied buffer, which JSC's decoder + // dereferences without a bounds check. + const code = /* js */ ` + const vm = require("node:vm"); + const assert = require("node:assert"); + + function corruptions(good) { + const variants = []; + // Truncations: len-1, len/2, 16, 1. The len/2 case is the one that + // reliably reached the decoder's wild pointer arithmetic before the + // envelope check was added. + for (const cut of [good.length - 1, good.length >> 1, 16, 1]) { + variants.push(["truncate:" + cut, good.subarray(0, cut)]); + } + // 20 single-byte flips seeded deterministically across the buffer. + for (let i = 0; i < 20; i++) { + const copy = Buffer.from(good); + const off = Math.floor((i * (good.length - 1)) / 19); + copy[off] ^= 0xff; + variants.push(["flip@" + off, copy]); + } + variants.push(["extend", Buffer.concat([good, Buffer.from([0xde, 0xad])])]); + variants.push(["empty", Buffer.alloc(0)]); + variants.push(["zeros", Buffer.alloc(good.length)]); + variants.push(["fill", Buffer.alloc(good.length, 0x2a)]); + variants.push(["random", Buffer.from("fhqwhgads")]); + return variants; + } + + { + const source = "function f(){return 1234} f()"; + const good = new vm.Script(source).createCachedData(); + for (const [label, cachedData] of corruptions(good)) { + const script = new vm.Script(source, { cachedData }); + assert.strictEqual(script.cachedDataRejected, true, label); + assert.strictEqual(script.runInThisContext(), 1234, label); + } + const intact = new vm.Script(source, { cachedData: good }); + assert.strictEqual(intact.cachedDataRejected, false); + assert.strictEqual(intact.runInThisContext(), 1234); + // Intact bytecode for a different source is also rejected (sourceHash). + const other = new vm.Script("1 + 1", { cachedData: good }); + assert.strictEqual(other.cachedDataRejected, true); + assert.strictEqual(other.runInThisContext(), 2); + console.log("script ok"); + } + + { + const params = ["a"]; + const source = "return a + 1234;"; + const good = vm.compileFunction(source, params, { produceCachedData: true }).cachedData; + assert.ok(good.length > 0); + for (const [label, cachedData] of corruptions(good)) { + const fn = vm.compileFunction(source, params, { cachedData }); + assert.strictEqual(fn.cachedDataRejected, true, label); + assert.strictEqual(fn(1), 1235, label); + } + const intact = vm.compileFunction(source, params, { cachedData: good }); + assert.strictEqual(intact.cachedDataRejected, false); + assert.strictEqual(intact(1), 1235); + const other = vm.compileFunction("return a + 1;", params, { cachedData: good }); + assert.strictEqual(other.cachedDataRejected, true); + assert.strictEqual(other(1), 2); + console.log("compileFunction ok"); + } + + { + const source = "export const value = 1234;"; + const good = new vm.SourceTextModule(source).createCachedData(); + for (const [label, cachedData] of corruptions(good)) { + assert.throws(() => new vm.SourceTextModule(source, { cachedData }), { + code: "ERR_VM_MODULE_CACHED_DATA_REJECTED", + }, label); + } + assert.throws(() => new vm.SourceTextModule("export const value = 5678;", { cachedData: good }), { + code: "ERR_VM_MODULE_CACHED_DATA_REJECTED", + }); + new vm.SourceTextModule(source, { cachedData: good }); + console.log("module ok"); + } + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "script ok\ncompileFunction ok\nmodule ok\n", + stderr: "", + exitCode: 0, + signalCode: null, + }); +}); + describe("codeGeneration options", () => { test("disabling codeGeneration.strings should block eval and Function constructor", () => { const context = createContext(