diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index b3f4c90e1823..f53ff94d5010 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -3367,15 +3367,19 @@ JSC::EncodedJSValue JSC__JSValue__fromEntries(JSC::JSGlobalObject* globalObject, if (!clone) { for (size_t i = 0; i < initialCapacity; ++i) { + auto* value = Zig::toJSStringGC(values[i], globalObject); + RETURN_IF_EXCEPTION(scope, {}); object->putDirect( vm, JSC::PropertyName(JSC::Identifier::fromString(vm, Zig::toString(keys[i]))), - Zig::toJSStringGC(values[i], globalObject), 0); + value, 0); RETURN_IF_EXCEPTION(scope, {}); } } else { for (size_t i = 0; i < initialCapacity; ++i) { + auto* value = Zig::toJSStringGC(values[i], globalObject); + RETURN_IF_EXCEPTION(scope, {}); object->putDirect(vm, JSC::PropertyName(Zig::toIdentifier(keys[i], globalObject)), - Zig::toJSStringGC(values[i], globalObject), 0); + value, 0); RETURN_IF_EXCEPTION(scope, {}); } } @@ -3700,7 +3704,7 @@ __attribute__((__always_inline__)) VirtualMachine* JSC__JSGlobalObject__bunVM(JS JSC::EncodedJSValue ZigString__toValueGC(const ZigString* arg0, JSC::JSGlobalObject* arg1) { - return JSC::JSValue::encode(JSC::jsString(arg1->vm(), Zig::toStringCopy(*arg0))); + return JSC::JSValue::encode(Zig::toJSStringGC(*arg0, arg1)); } void JSC__JSValue__toZigString(JSC::EncodedJSValue JSValue0, ZigString* arg1, JSC::JSGlobalObject* arg2) diff --git a/src/jsc/bindings/helpers.h b/src/jsc/bindings/helpers.h index ac788968f214..16f17d988bd6 100644 --- a/src/jsc/bindings/helpers.h +++ b/src/jsc/bindings/helpers.h @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -25,6 +26,13 @@ class GlobalObject; extern "C" size_t Bun__stringSyntheticAllocationLimit; extern "C" const char* Bun__errnoName(int); +namespace Bun { +namespace ERR { +// Forward declaration: ErrorCode.h transitively includes this header. +JSC::EncodedJSValue STRING_TOO_LONG(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject); +} +} + namespace Zig { // 8 bit byte @@ -191,6 +199,11 @@ static const WTF::String toStringCopy(ZigString str) return WTF::String::fromUTF8ReplacingInvalidSequences(std::span { untag(str.ptr), str.len }); } + // This will fail if the string is too long. Let's make it explicit instead of an ASSERT. + if (str.len > Bun__stringSyntheticAllocationLimit || str.len > WTF::String::MaxLength) [[unlikely]] { + return {}; + } + if (isTaggedUTF16Ptr(str.ptr)) { std::span out; auto impl = WTF::StringImpl::tryCreateUninitialized(str.len, out); @@ -240,9 +253,28 @@ static const JSC::JSString* toJSString(ZigString str, JSC::JSGlobalObject* globa return JSC::jsOwnedString(global->vm(), toString(str)); } +// Copies `str` into a GC-managed JSString. Throws ERR_STRING_TOO_LONG (or an +// out-of-memory error) and returns nullptr when the string cannot be created. static JSC::JSString* toJSStringGC(ZigString str, JSC::JSGlobalObject* global) { - return JSC::jsString(global->vm(), toStringCopy(str)); + auto wtfString = toStringCopy(str); + if (wtfString.isNull() && str.len > 0) [[unlikely]] { + auto scope = DECLARE_THROW_SCOPE(global->vm()); + size_t maxLength = std::min(Bun__stringSyntheticAllocationLimit, static_cast(WTF::String::MaxLength)); + // For UTF-8 input `str.len` counts bytes, but the limit applies to the + // decoded UTF-16 length. + size_t decodedLength = str.len; + if (isTaggedUTF8Ptr(str.ptr) && str.len > maxLength) { + decodedLength = simdutf::utf16_length_from_utf8(reinterpret_cast(untag(str.ptr)), str.len); + } + if (decodedLength > maxLength) { + Bun::ERR::STRING_TOO_LONG(scope, global); + } else { + JSC::throwOutOfMemoryError(global, scope); + } + return nullptr; + } + return JSC::jsString(global->vm(), WTF::move(wtfString)); } static const ZigString ZigStringEmpty = ZigString { (unsigned char*)"", 0 }; diff --git a/test/js/web/encoding/text-decoder.test.js b/test/js/web/encoding/text-decoder.test.js index 36c3612d9696..1c083cab3d69 100644 --- a/test/js/web/encoding/text-decoder.test.js +++ b/test/js/web/encoding/text-decoder.test.js @@ -9,6 +9,7 @@ import { tempDir, withoutAggressiveGC, } from "harness"; +import os from "node:os"; const getByteLength = str => { // returns the byte length of an utf8 string @@ -947,3 +948,45 @@ it.each(["utf-16le", "utf-16be"])( }, 30_000, ); // 3072 decodes + repeated Bun.gc(true) take ~4s under ASAN. + +describe("TextDecoder.decode above the maximum string length", () => { + const expected = "threw:ERR_STRING_TOO_LONG:Cannot create a string longer than 2147483647 characters"; + const script = size => ` + try { + const s = new TextDecoder().decode(new Uint8Array(${size})); + console.log("returned:" + s.length); + } catch (e) { + console.log("threw:" + e.code + ":" + e.message); + }`; + + // A zeroed input is all-ASCII, so the decoded string's length equals the + // input length. 2**31 exceeds WTF::StringImpl's maximum length (2^31-1); + // decode() used to return "" silently instead of throwing. The input is + // never written (untouched zero pages) and the throw happens before the + // output string is allocated, so this runs in about a second even under + // debug + ASAN. Skipped on small machines where the 2 GiB reservation + // itself can fail (same gate as blob-oom.test.ts and fs-oom.test.ts). + it.skipIf(os.totalmem() < 10 * 1024 ** 3)("throws ERR_STRING_TOO_LONG for a 2**31 byte ASCII input", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script("2 ** 31")], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe(expected); + expect(exitCode).toBe(0); + }); + + // Same path, exercised cheaply via the synthetic allocation limit so the + // guard is covered without a 2 GiB allocation. + it("throws ERR_STRING_TOO_LONG above the synthetic allocation limit", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script("160 * 1024 * 1024")], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: "134217728" }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe(expected); + expect(exitCode).toBe(0); + }); +});