From dab5cc178aebfb3f8f131c7efce59350359d938e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:25:56 +0000 Subject: [PATCH 1/2] Throw instead of aborting when a script-sized Vector cannot grow require.resolve(id, { paths }), Module._resolveFilename(id, parent, isMain, { paths }) and new Worker(url, { preload }) copy a script-sized array of strings into a WTF::Vector with append() or reserveInitialCapacity(), which call CRASH() when the Vector cannot hold the entries. 77,731,543 paths or 89,478,486 preload entries aborted the process. Add Bun::tryAppendWithinLimit and Bun::tryReserveCapacityWithinLimit next to Bun::maxVectorSize and use them at the three sites. A refused entry throws RangeError: Out of memory. The three existing hand-written copies of the check in the web streams now call the same helpers. --- src/jsc/bindings/ImportMetaObject.cpp | 8 ++- src/jsc/bindings/VectorSizeLimit.h | 13 +++++ .../bindings/webcore/WorkerMessagingProxy.cpp | 5 +- .../webcore/streams/BunStandaloneTextSink.h | 4 +- .../webcore/streams/BunStreamConsumers.cpp | 2 +- src/jsc/modules/NodeModuleModule.cpp | 7 ++- .../js/node/module/node-module-module.test.js | 50 +++++++++++++++++++ test/js/web/workers/worker.test.ts | 36 +++++++++++++ 8 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index dd998bb947f7..6ce8a54530ec 100644 --- a/src/jsc/bindings/ImportMetaObject.cpp +++ b/src/jsc/bindings/ImportMetaObject.cpp @@ -49,6 +49,7 @@ #include "wtf/text/StringView.h" #include "isBuiltinModule.h" +#include "VectorSizeLimit.h" #include "WebCoreJSBuiltins.h" namespace Zig { @@ -289,7 +290,12 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSyncPrivate(JSC::JSGlo WTF::String pathStr = path.toWTFString(globalObject); if (scope.exception()) [[unlikely]] goto cleanup; - paths.append(Bun::toStringRef(pathStr)); + BunString pathRef = Bun::toStringRef(pathStr); + if (!Bun::tryAppendWithinLimit(paths, pathRef)) [[unlikely]] { + pathRef.deref(); + throwOutOfMemoryError(globalObject, scope); + goto cleanup; + } } result = Bun__resolveSyncWithPaths(lexicalGlobalObject, JSC::JSValue::encode(moduleName), JSValue::encode(from), isESM, isRequireDotResolve, paths.begin(), paths.size()); diff --git a/src/jsc/bindings/VectorSizeLimit.h b/src/jsc/bindings/VectorSizeLimit.h index 56d49a53b165..6296380afd80 100644 --- a/src/jsc/bindings/VectorSizeLimit.h +++ b/src/jsc/bindings/VectorSizeLimit.h @@ -25,4 +25,17 @@ size_t maxDequeSize() return std::max(std::bit_floor(maxVectorSize()), 1) - 1; } +// Fallible append and reserve for a Vector that script sizes. False when the request passes maxVectorSize or the allocation fails, and the caller throws. +template +bool tryAppendWithinLimit(WTF::Vector& vector, U&& value) +{ + return vector.size() < maxVectorSize() && vector.tryAppend(std::forward(value)); +} + +template +bool tryReserveCapacityWithinLimit(WTF::Vector& vector, size_t capacity) +{ + return capacity <= maxVectorSize() && vector.tryReserveCapacity(capacity); +} + } // namespace Bun diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index cda4ce937db5..90c6414aaa17 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -36,6 +36,7 @@ #include "MessageEvent.h" #include "MessagePort.h" #include "SerializedScriptValue.h" +#include "VectorSizeLimit.h" #include "Worker.h" #include "ZigGlobalObject.h" #include @@ -121,8 +122,10 @@ ExceptionOr WorkerMessagingProxy::startWorkerGlobalScope(const String& scr return {}; } + // Script picks how many preload entries there are, so the reserve is fallible. Vector preloadModules; - preloadModules.reserveInitialCapacity(m_options.preloadModules.size()); + if (!Bun::tryReserveCapacityWithinLimit(preloadModules, m_options.preloadModules.size())) [[unlikely]] + return Exception { OutOfMemoryError }; for (auto& str : m_options.preloadModules) { if (str.startsWith("file://"_s)) { WTF::URL urlObject = WTF::URL(str); diff --git a/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h b/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h index fbd24d1ccdcf..2dd1ecdb73e0 100644 --- a/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h +++ b/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h @@ -45,11 +45,11 @@ struct BunTextAccumulator { { using Piece = JSC::WriteBarrier; if (flushedRope) { - if (pieces.size() >= Bun::maxVectorSize() || !pieces.tryAppend(Piece(vm, owner, flushedRope))) [[unlikely]] + if (!Bun::tryAppendWithinLimit(pieces, Piece(vm, owner, flushedRope))) [[unlikely]] return false; rope.clear(); } - return pieces.size() < Bun::maxVectorSize() && pieces.tryAppend(Piece(vm, owner, chunk)); + return Bun::tryAppendWithinLimit(pieces, Piece(vm, owner, chunk)); } // Releases everything accumulated. Called as soon as the final result string has diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index 7bc558ca3ea7..be5e38cc1f9d 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -361,7 +361,7 @@ static JSValue concatenateChunks(JSC::VM& vm, JSGlobalObject* globalObject, JSAr MarkedArgumentBuffer values; WTF::Vector, 16> stringChunks; // Script picks the chunk count here and the byte total below, so both reserves are fallible. - if (length > Bun::maxVectorSize>() || !stringChunks.tryReserveCapacity(length)) [[unlikely]] { + if (!Bun::tryReserveCapacityWithinLimit(stringChunks, length)) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); return {}; } diff --git a/src/jsc/modules/NodeModuleModule.cpp b/src/jsc/modules/NodeModuleModule.cpp index feea89f168f6..cefbacf952e0 100644 --- a/src/jsc/modules/NodeModuleModule.cpp +++ b/src/jsc/modules/NodeModuleModule.cpp @@ -20,6 +20,7 @@ #include "ZigGlobalObject.h" #include "headers.h" #include "ErrorCode.h" +#include "VectorSizeLimit.h" #include "GeneratedNodeModuleModule.h" #include "ZigGeneratedClasses.h" @@ -360,7 +361,11 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionResolveFileName, if (scope.exception()) return; - paths.append(Bun::toStringRef(pathStr)); + BunString pathRef = Bun::toStringRef(pathStr); + if (!Bun::tryAppendWithinLimit(paths, pathRef)) [[unlikely]] { + pathRef.deref(); + throwOutOfMemoryError(lexicalGlobalObject, scope); + } }); if (scope.exception()) { diff --git a/test/js/node/module/node-module-module.test.js b/test/js/node/module/node-module-module.test.js index 368685185c7f..6bfd0f0c979b 100644 --- a/test/js/node/module/node-module-module.test.js +++ b/test/js/node/module/node-module-module.test.js @@ -435,6 +435,56 @@ console.log("survived", require("./late.js"));`, expect(Module._resolveFilename("fs", null, false, { paths: null })).toBe("fs"); }); + // Script picks how many entries options.paths has, and each one is copied into a native list. A list that + // cannot grow must throw a catchable error, never abort the process. The real bound is 77,731,542 entries + // (5 GB), so the child runs with a 64 KiB synthetic allocation limit: 2730 entries of 24 bytes. + test("options.paths with more entries than the native list holds throws a RangeError", async () => { + const LIMIT_BYTES = 64 * 1024; + const MAX_PATHS = Math.floor(LIMIT_BYTES / 24); + using dir = tempDir("resolve-paths-limit", { + "lib/node_modules/limit-pkg/package.json": JSON.stringify({ name: "limit-pkg", main: "index.js" }), + "lib/node_modules/limit-pkg/index.js": "module.exports = 1;", + "main.cjs": ` + const path = require("node:path"); + const Module = require("node:module"); + const doors = { + "require.resolve": paths => require.resolve("limit-pkg", { paths }), + "Module._resolveFilename": paths => Module._resolveFilename("limit-pkg", module, false, { paths }), + }; + // Only the last entry holds the package, so a result proves that the whole list reached the resolver. + const resolveWith = (resolve, count) => { + const paths = new Array(count).fill(path.join(__dirname, "missing")); + paths[count - 1] = path.join(__dirname, "lib"); + try { + return path.relative(__dirname, resolve(paths)); + } catch (e) { + return e.name + ": " + e.message; + } + }; + const result = {}; + for (const [name, resolve] of Object.entries(doors)) { + result[name] = { atLimit: resolveWith(resolve, ${MAX_PATHS}), onePast: resolveWith(resolve, ${MAX_PATHS + 1}) }; + } + console.log(JSON.stringify(result)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(String(dir), "main.cjs")], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(LIMIT_BYTES) }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const resolved = path.join("lib", "node_modules", "limit-pkg", "index.js"); + expect({ stdout: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + stdout: { + "require.resolve": { atLimit: resolved, onePast: "RangeError: Out of memory" }, + "Module._resolveFilename": { atLimit: resolved, onePast: "RangeError: Out of memory" }, + }, + stderr: "", + exitCode: 0, + }); + }); + test("createRequire trailing slash", () => { const req = createRequire(import.meta.dir + "/"); expect(req.resolve("./node-module-module.test.js")).toBe( diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 102b6c6f7dbd..7bb6a8081606 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -29,6 +29,42 @@ describe("web worker", () => { ).toThrow(/Invalid file URL/); }); + // Script picks how many entries preload has, and each one is copied into a native list. A list that cannot + // hold them must throw a catchable error, never abort the process. The real bound is 89,478,485 entries + // (2.5 GB), so the child runs with a 64 KiB synthetic allocation limit: 2730 entries of 24 bytes. + test("more entries than the native list holds throws a RangeError", async () => { + const LIMIT_BYTES = 64 * 1024; + const MAX_ENTRIES = Math.floor(LIMIT_BYTES / 24); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // The last entry is an invalid file URL. A list that fits throws for that entry, so no thread starts. + const construct = count => { + const preload = new Array(count).fill("./preload.js"); + preload[count - 1] = "file://:!:!:!!!!"; + try { + new Worker("./entry.js", { preload }); + return "constructed"; + } catch (e) { + return e.name + ": " + e.message; + } + }; + console.log(JSON.stringify({ atLimit: construct(${MAX_ENTRIES}), onePast: construct(${MAX_ENTRIES + 1}) })); + `, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(LIMIT_BYTES) }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + stdout: { atLimit: 'TypeError: Invalid file URL: "file://:!:!:!!!!"', onePast: "RangeError: Out of memory" }, + stderr: "", + exitCode: 0, + }); + }); + test("string", async () => { const worker = new Worker(new URL("worker-fixture-preload-entry.js", import.meta.url).href, { preload: new URL("worker-fixture-preload.js", import.meta.url).href, From 64fa907042e0875d7d6117db81a651512d819bed Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:49:40 +0000 Subject: [PATCH 2/2] test: assert the child's stderr and stdout before its exit code --- test/js/node/module/node-module-module.test.js | 12 +++++------- test/js/web/workers/worker.test.ts | 9 +++++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/test/js/node/module/node-module-module.test.js b/test/js/node/module/node-module-module.test.js index 6bfd0f0c979b..a6cf10f444ce 100644 --- a/test/js/node/module/node-module-module.test.js +++ b/test/js/node/module/node-module-module.test.js @@ -475,14 +475,12 @@ console.log("survived", require("./late.js"));`, }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const resolved = path.join("lib", "node_modules", "limit-pkg", "index.js"); - expect({ stdout: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ - stdout: { - "require.resolve": { atLimit: resolved, onePast: "RangeError: Out of memory" }, - "Module._resolveFilename": { atLimit: resolved, onePast: "RangeError: Out of memory" }, - }, - stderr: "", - exitCode: 0, + expect(stderr).toBe(""); + expect(JSON.parse(stdout || "null")).toEqual({ + "require.resolve": { atLimit: resolved, onePast: "RangeError: Out of memory" }, + "Module._resolveFilename": { atLimit: resolved, onePast: "RangeError: Out of memory" }, }); + expect(exitCode).toBe(0); }); test("createRequire trailing slash", () => { diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 7bb6a8081606..c24e4d94fcc6 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -58,11 +58,12 @@ describe("web worker", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ - stdout: { atLimit: 'TypeError: Invalid file URL: "file://:!:!:!!!!"', onePast: "RangeError: Out of memory" }, - stderr: "", - exitCode: 0, + expect(stderr).toBe(""); + expect(JSON.parse(stdout || "null")).toEqual({ + atLimit: 'TypeError: Invalid file URL: "file://:!:!:!!!!"', + onePast: "RangeError: Out of memory", }); + expect(exitCode).toBe(0); }); test("string", async () => {