diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 60ec59919fcc..877a2b11690e 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -108,23 +108,30 @@ static JSValue scriptFetchParametersToImportAttributes(JSGlobalObject* globalObj return obj; } -bool extractCachedData(JSValue cachedDataValue, WTF::Vector& outCachedData) +CachedDataExtraction extractCachedData(JSValue cachedDataValue, WTF::Vector& outCachedData) { if (!cachedDataValue.isCell()) { - return false; + return CachedDataExtraction::NotABuffer; } + std::span bytes; if (auto* arrayBufferView = dynamicDowncast(cachedDataValue)) { - if (!arrayBufferView->isDetached()) { - outCachedData = arrayBufferView->span(); - return true; + if (arrayBufferView->isDetached()) { + return CachedDataExtraction::NotABuffer; } + bytes = arrayBufferView->span(); } else if (auto* arrayBuffer = dynamicDowncast(cachedDataValue); arrayBuffer && arrayBuffer->impl()) { - outCachedData = arrayBuffer->impl()->toVector(); - return true; + bytes = arrayBuffer->impl()->span(); + } else { + return CachedDataExtraction::NotABuffer; } - return false; + if (!WTF::isValidCapacityForVector(bytes.size())) { + return CachedDataExtraction::TooLong; + } + + outCachedData = bytes; + return CachedDataExtraction::Copied; } JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, const ArgList& args, const SourceOrigin& sourceOrigin, CompileFunctionOptions&& options, JSC::SourceTaintedOrigin sourceTaintOrigin, JSC::JSScope* scope) @@ -218,7 +225,9 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c TriState bytecodeAccepted = TriState::Indeterminate; - if (!options.cachedData.isEmpty()) { + if (options.cachedDataTooLong) { + bytecodeAccepted = TriState::False; + } else 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); @@ -1898,15 +1907,21 @@ bool BaseVMOptions::validateProduceCachedData(JSC::JSGlobalObject* globalObject, return false; } -bool BaseVMOptions::validateCachedData(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC::ThrowScope& scope, JSObject* options, WTF::Vector& outCachedData) +bool BaseVMOptions::validateCachedData(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC::ThrowScope& scope, JSObject* options, WTF::Vector& outCachedData, bool& outCachedDataTooLong) { JSValue cachedDataOpt = options->getIfPropertyExists(globalObject, Identifier::fromString(vm, "cachedData"_s)); RETURN_IF_EXCEPTION(scope, {}); if (cachedDataOpt && !cachedDataOpt.isUndefined()) { // Verify it's a Buffer, TypedArray or DataView and extract the data if it is. - if (extractCachedData(cachedDataOpt, outCachedData)) { + switch (extractCachedData(cachedDataOpt, outCachedData)) { + case CachedDataExtraction::Copied: + return true; + case CachedDataExtraction::TooLong: + outCachedDataTooLong = true; return true; + case CachedDataExtraction::NotABuffer: + break; } ERR::INVALID_ARG_INSTANCE(scope, globalObject, "options.cachedData"_s, "Buffer, TypedArray, or DataView"_s, cachedDataOpt); @@ -1960,7 +1975,7 @@ bool CompileFunctionOptions::fromJS(JSC::JSGlobalObject* globalObject, JSC::VM& // The validators return false both for "absent" and for "threw". RETURN_IF_EXCEPTION(scope, false); - if (validateCachedData(globalObject, vm, scope, options, this->cachedData)) + if (validateCachedData(globalObject, vm, scope, options, this->cachedData, this->cachedDataTooLong)) any = true; RETURN_IF_EXCEPTION(scope, false); diff --git a/src/jsc/bindings/NodeVM.h b/src/jsc/bindings/NodeVM.h index f58e829ed372..6ba604bddbdc 100644 --- a/src/jsc/bindings/NodeVM.h +++ b/src/jsc/bindings/NodeVM.h @@ -25,7 +25,16 @@ class CompileFunctionOptions; namespace NodeVM { RefPtr getBytecode(JSGlobalObject* globalObject, JSC::SourceCodeType, const JSC::SourceCode& source); -bool extractCachedData(JSValue cachedDataValue, WTF::Vector& outCachedData); +enum class CachedDataExtraction : uint8_t { + // Not a Buffer, TypedArray, DataView or ArrayBuffer, or a detached view. + NotABuffer, + Copied, + // Longer than a WTF::Vector holds (INT_MAX), so not copied. The caller reports the data as rejected, which is + // also Node's result unless the buffer starts with a valid cache (its length narrows to V8's `int` there): + // https://github.com/nodejs/node/blob/v26.3.0/src/node_contextify.cc#L1027-L1028 + TooLong, +}; +CachedDataExtraction extractCachedData(JSValue cachedDataValue, WTF::Vector& outCachedData); 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); @@ -59,7 +68,7 @@ class BaseVMOptions { bool fromJS(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC::ThrowScope& scope, JSC::JSValue optionsArg); bool validateProduceCachedData(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC::ThrowScope& scope, JSObject* options, bool& outProduceCachedData); - bool validateCachedData(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC::ThrowScope& scope, JSObject* options, WTF::Vector& outCachedData); + bool validateCachedData(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC::ThrowScope& scope, JSObject* options, WTF::Vector& outCachedData, bool& outCachedDataTooLong); bool validateTimeout(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC::ThrowScope& scope, JSObject* options, std::optional& outTimeout); }; @@ -69,6 +78,8 @@ class CompileFunctionOptions : public BaseVMOptions { JSGlobalObject* parsingContext = nullptr; JSValue contextExtensions {}; bool produceCachedData = false; + // See NodeVM::CachedDataExtraction::TooLong. + bool cachedDataTooLong = false; using BaseVMOptions::BaseVMOptions; diff --git a/src/jsc/bindings/NodeVMScript.cpp b/src/jsc/bindings/NodeVMScript.cpp index 6f8303a8e251..f0f7807a8eb8 100644 --- a/src/jsc/bindings/NodeVMScript.cpp +++ b/src/jsc/bindings/NodeVMScript.cpp @@ -66,7 +66,7 @@ bool ScriptOptions::fromJS(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC:: any = true; RETURN_IF_EXCEPTION(scope, false); - if (validateCachedData(globalObject, vm, scope, options, this->cachedData)) + if (validateCachedData(globalObject, vm, scope, options, this->cachedData, this->cachedDataTooLong)) any = true; RETURN_IF_EXCEPTION(scope, false); @@ -169,7 +169,9 @@ constructScript(JSGlobalObject* globalObject, CallFrame* callFrame, JSValue newT WTF::Vector& cachedData = script->cachedData(); - if (!cachedData.isEmpty()) { + if (script->options().cachedDataTooLong) { + script->cachedDataRejected(TriState::True); + } else if (!cachedData.isEmpty()) { JSC::ProgramExecutable* executable = script->cachedExecutable(); if (!executable) { executable = script->createExecutable(); diff --git a/src/jsc/bindings/NodeVMScript.h b/src/jsc/bindings/NodeVMScript.h index 50a133f459fc..3c8f847e1f75 100644 --- a/src/jsc/bindings/NodeVMScript.h +++ b/src/jsc/bindings/NodeVMScript.h @@ -13,6 +13,8 @@ class ScriptOptions : public BaseVMOptions { WTF::Vector cachedData; std::optional timeout = std::nullopt; bool produceCachedData = false; + // See NodeVM::CachedDataExtraction::TooLong. + bool cachedDataTooLong = false; using BaseVMOptions::BaseVMOptions; diff --git a/src/jsc/bindings/NodeVMSourceTextModule.cpp b/src/jsc/bindings/NodeVMSourceTextModule.cpp index 0ab7ce94d0f4..44c359bd5e00 100644 --- a/src/jsc/bindings/NodeVMSourceTextModule.cpp +++ b/src/jsc/bindings/NodeVMSourceTextModule.cpp @@ -60,9 +60,18 @@ NodeVMSourceTextModule* NodeVMSourceTextModule::create(VM& vm, JSGlobalObject* g JSValue cachedDataValue = args.at(5); WTF::Vector cachedData; - if (!cachedDataValue.isUndefined() && !extractCachedData(cachedDataValue, cachedData)) { - Bun::ERR::INVALID_ARG_INSTANCE(scope, globalObject, "options.cachedData"_s, "Buffer, TypedArray, or DataView"_s, cachedDataValue); - return nullptr; + bool cachedDataTooLong = false; + if (!cachedDataValue.isUndefined()) { + switch (extractCachedData(cachedDataValue, cachedData)) { + case CachedDataExtraction::Copied: + break; + case CachedDataExtraction::TooLong: + cachedDataTooLong = true; + break; + case CachedDataExtraction::NotABuffer: + Bun::ERR::INVALID_ARG_INSTANCE(scope, globalObject, "options.cachedData"_s, "Buffer, TypedArray, or DataView"_s, cachedDataValue); + return nullptr; + } } JSValue initializeImportMeta = args.at(6); @@ -112,7 +121,7 @@ NodeVMSourceTextModule* NodeVMSourceTextModule::create(VM& vm, JSGlobalObject* g WTF::move(sourceCode), moduleWrapper, initializeImportMeta); ptr->finishCreation(vm); - if (cachedData.isEmpty()) { + if (cachedData.isEmpty() && !cachedDataTooLong) { return ptr; } @@ -124,6 +133,11 @@ NodeVMSourceTextModule* NodeVMSourceTextModule::create(VM& vm, JSGlobalObject* g return nullptr; } + if (cachedDataTooLong) { + throwError(globalObject, scope, ErrorCode::ERR_VM_MODULE_CACHED_DATA_REJECTED, "cachedData buffer was rejected"_s); + return nullptr; + } + // Decoding checks the format and the source key. Linking would need // the module's JSModuleEnvironment, which does not exist yet. LexicallyScopedFeatures lexicallyScopedFeatures = StrictModeLexicallyScopedFeature; diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index c32e0253f5da..212056f863a8 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -933,6 +933,59 @@ test("SourceTextModule accepts the cachedData it produced", () => { ); }); +test("cachedData of 2 GiB or more is reported as rejected instead of aborting", async () => { + // The child reserves 2 GiB of address space and never writes to it, so RSS stays small. + const fixture = ` + const vm = require("node:vm"); + let buffer; + try { + buffer = new ArrayBuffer(2 ** 31); + } catch { + console.log("SKIP"); + process.exit(0); + } + const result = {}; + // A bare ArrayBuffer is a Bun extension: Node takes views only. + for (const cachedData of [new Uint8Array(buffer), new DataView(buffer), buffer]) { + const script = new vm.Script("1 + 1", { cachedData }); + const fn = vm.compileFunction("return 2 + 2", [], { cachedData }); + result[cachedData.constructor.name] = { + Script: [script.cachedDataRejected, script.runInThisContext()], + compileFunction: [fn.cachedDataRejected, fn()], + }; + } + try { + result.SourceTextModule = new vm.SourceTextModule("export default 1", { cachedData: new Uint8Array(buffer) }).status; + } catch (e) { + result.SourceTextModule = e.code; + } + console.log(JSON.stringify(result)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { + ...bunEnv, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "allocator_may_return_null=1"].filter(Boolean).join(":"), + }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The child prints SKIP when it cannot reserve 2 GiB. + if (stdout.trim() !== "SKIP") { + const rejected = { Script: [true, 2], compileFunction: [true, 4] }; + expect({ stdout: stdout && JSON.parse(stdout), stderr }).toEqual({ + stdout: { + Uint8Array: rejected, + DataView: rejected, + ArrayBuffer: rejected, + SourceTextModule: "ERR_VM_MODULE_CACHED_DATA_REJECTED", + }, + stderr: "", + }); + } + expect(exitCode).toBe(0); +}); + describe("Script compiles its source once and links that in every context it runs in", () => { // Runs Script(s) in fresh contexts, keeping what every run produced alive (each run's wrapper function // pins that run's ProgramExecutable), and reports how many UnlinkedProgramCodeBlock cells (one per