From cf3904ad6c705f7e691fe0517277e1873ee29410 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 16 Sep 2026 17:06:06 -0700 Subject: [PATCH 1/5] Blob/URL: identify Blobs by our own constructor; make URL's static methods writable Blob::TryGetData compared an object's prototype chain against whatever the global `Blob` binding held at call time. A script that replaces the global Blob with its own class (BabylonNative's validation harness does, for image loading) made every instance of that class look like one of ours, and Unwrap on an object that wraps nothing threw a bare "Invalid argument" out of URL.createObjectURL -- while a real Blob was rejected as "not a Blob". The constructor is now kept on the runtime's native object at Initialize and identity is checked against that; a null Unwrap yields "not a Blob" like any other foreign object. The same harness assigns its own URL.createObjectURL. napi_default makes static methods read-only, so that assignment failed silently and the native method kept receiving the foreign Blob. WebIDL static operations are writable, enumerable and configurable; declare the URL statics that way. Found by the SOG Gaussian-splat scenes of BabylonNative's Dawn validation catalog ("Failed to parse SOG zip data", cause "Invalid argument"); they pass again with this change. Regression tests cover both halves. --- Polyfills/Blob/Source/Blob.cpp | 12 ++++++++++- Polyfills/URL/Source/URL.cpp | 16 +++++++++++---- Tests/UnitTests/Scripts/tests.ts | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index 242763fe..f7b79099 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -21,6 +21,10 @@ namespace Babylon::Polyfills::Internal InstanceMethod("bytes", &Blob::Bytes), }); + // TryGetData identifies our instances through this reference, so a script that later + // replaces the global `Blob` (a test shim, another polyfill) cannot make a foreign object + // look like one of ours. + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_BLOB_CONSTRUCTOR_NAME, func); env.Global().Set(JS_BLOB_CONSTRUCTOR_NAME, func); } } @@ -156,7 +160,9 @@ namespace Babylon::Polyfills::Blob // This keeps the check portable across QuickJS, V8, JavaScriptCore, Chakra and JSI, and it // also accepts Blob subclasses (e.g. File). We deliberately avoid napi_instanceof, whose // node-addon-api wrapper requires a Napi::Function. - const auto blobConstructor = global.Get("Blob"); + // Our constructor is the one registered at Initialize, not the global `Blob` binding: a + // script may have replaced that with its own class whose instances wrap nothing. + const auto blobConstructor = JsRuntime::NativeObject::GetFromJavaScript(env).Get("Blob"); if (!blobConstructor.IsFunction()) { return std::nullopt; @@ -200,6 +206,10 @@ namespace Babylon::Polyfills::Blob } const auto* blob = Internal::Blob::Unwrap(object); + if (blob == nullptr) + { + return std::nullopt; + } return BlobData{blob->Data(), blob->Type()}; } } diff --git a/Polyfills/URL/Source/URL.cpp b/Polyfills/URL/Source/URL.cpp index 3d2b9d7d..eb61eb95 100644 --- a/Polyfills/URL/Source/URL.cpp +++ b/Polyfills/URL/Source/URL.cpp @@ -456,6 +456,11 @@ namespace namespace Babylon::Polyfills::Internal { + namespace + { + constexpr auto WebIdlOperationAttributes = static_cast(napi_writable | napi_enumerable | napi_configurable); + } + static constexpr auto JS_URL_CONSTRUCTOR_NAME = "URL"; void URL::Initialize(Napi::Env env) @@ -485,10 +490,13 @@ namespace Babylon::Polyfills::Internal InstanceMethod("toString", &URL::ToString), InstanceMethod("toJSON", &URL::ToJSON), // Static methods - StaticMethod("canParse", &URL::CanParse), - StaticMethod("parse", &URL::Parse), - StaticMethod("createObjectURL", &URL::CreateObjectURL), - StaticMethod("revokeObjectURL", &URL::RevokeObjectURL), + // WebIDL static operations are writable, enumerable and configurable (a page can + // replace URL.createObjectURL); napi_default would make them read-only and let + // such an assignment fail silently. + StaticMethod("canParse", &URL::CanParse, WebIdlOperationAttributes), + StaticMethod("parse", &URL::Parse, WebIdlOperationAttributes), + StaticMethod("createObjectURL", &URL::CreateObjectURL, WebIdlOperationAttributes), + StaticMethod("revokeObjectURL", &URL::RevokeObjectURL, WebIdlOperationAttributes), }); env.Global().Set(JS_URL_CONSTRUCTOR_NAME, func); diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 654c60b6..edb32de1 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1306,6 +1306,40 @@ describe("URL.createObjectURL", function () { expect(() => URL.createObjectURL("not a blob" as any)).to.throw(); }); + it("identifies real Blobs, and rejects look-alikes, after a script replaces the global Blob", function () { + // A page or test harness may install its own Blob class. Identity must come from the + // polyfill's own constructor: a real Blob still works and a foreign instance is a clean + // TypeError, not a bare "Invalid argument" from unwrapping an object that wraps nothing. + const NativeBlob = Blob; + const real = new NativeBlob(["hello"], { type: "text/plain" }); + class LookAlikeBlob { + size = 5; + type = "text/plain"; + } + (globalThis as any).Blob = LookAlikeBlob; + try { + const url = URL.createObjectURL(real); + expect(url.indexOf("blob:")).to.equal(0); + URL.revokeObjectURL(url); + expect(() => URL.createObjectURL(new LookAlikeBlob() as any)).to.throw(TypeError, /not a Blob/); + } finally { + (globalThis as any).Blob = NativeBlob; + } + }); + + it("exposes createObjectURL as a writable, configurable static (WebIDL operation)", function () { + const descriptor = Object.getOwnPropertyDescriptor(URL, "createObjectURL")!; + expect(descriptor.writable, "writable").to.equal(true); + expect(descriptor.configurable, "configurable").to.equal(true); + const original = URL.createObjectURL; + try { + (URL as any).createObjectURL = () => "blob:replaced"; + expect(URL.createObjectURL(new Blob([]))).to.equal("blob:replaced"); + } finally { + (URL as any).createObjectURL = original; + } + }); + it("resolves a blob: URL through fetch (text + content-type)", async function () { const url = URL.createObjectURL(new Blob(["hello blob"], { type: "text/plain" })); const response = await fetch(url); From b374eb4e83f596662ef0516c8591cdf3aa04514d Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 16 Sep 2026 17:11:10 -0700 Subject: [PATCH 2/5] Tests: reach the global object without globalThis (ChakraCore lacks it) --- Tests/UnitTests/Scripts/tests.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index edb32de1..94251e55 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1310,20 +1310,22 @@ describe("URL.createObjectURL", function () { // A page or test harness may install its own Blob class. Identity must come from the // polyfill's own constructor: a real Blob still works and a foreign instance is a clean // TypeError, not a bare "Invalid argument" from unwrapping an object that wraps nothing. + // ChakraCore has no globalThis; the sloppy-mode Function trick reaches the global object everywhere. + const globalObject: any = Function("return this")(); const NativeBlob = Blob; const real = new NativeBlob(["hello"], { type: "text/plain" }); class LookAlikeBlob { size = 5; type = "text/plain"; } - (globalThis as any).Blob = LookAlikeBlob; + globalObject.Blob = LookAlikeBlob; try { const url = URL.createObjectURL(real); expect(url.indexOf("blob:")).to.equal(0); URL.revokeObjectURL(url); expect(() => URL.createObjectURL(new LookAlikeBlob() as any)).to.throw(TypeError, /not a Blob/); } finally { - (globalThis as any).Blob = NativeBlob; + globalObject.Blob = NativeBlob; } }); From e27baca578a05f335bad1ea70c80c2fdee326498 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 16 Sep 2026 17:36:54 -0700 Subject: [PATCH 3/5] Node-API-JSI: honour napi_writable for method properties; assert the look-alike rejection by message --- Core/Node-API-JSI/Include/napi/napi-inl.h | 4 ++++ Tests/UnitTests/Scripts/tests.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Core/Node-API-JSI/Include/napi/napi-inl.h b/Core/Node-API-JSI/Include/napi/napi-inl.h index 14fb745c..d9473742 100644 --- a/Core/Node-API-JSI/Include/napi/napi-inl.h +++ b/Core/Node-API-JSI/Include/napi/napi-inl.h @@ -2315,12 +2315,14 @@ ObjectWrap::DefineClass(napi_env env, prototype; if (p.staticVoidMethod != nullptr) { + descriptor.setProperty(rt, "writable", jsi::Value{(p.attributes & napi_writable) != 0}); descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.staticVoidMethod}, data{p.data}](jsi::Runtime& /*rt*/, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { (*method)({env, thisVal, args, count, nullptr, data}); return {}; })); } else if (p.staticMethod != nullptr) { + descriptor.setProperty(rt, "writable", jsi::Value{(p.attributes & napi_writable) != 0}); descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.staticMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { return {rt, (*method)({env, thisVal, args, count, nullptr, data})}; @@ -2344,6 +2346,7 @@ ObjectWrap::DefineClass(napi_env env, descriptor.setProperty(rt, "writable", jsi::Value{(p.attributes & napi_writable) != 0}); descriptor.setProperty(rt, "value", static_cast(p.staticValue)); } else if (p.instanceVoidMethod != nullptr) { + descriptor.setProperty(rt, "writable", jsi::Value{(p.attributes & napi_writable) != 0}); descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.instanceVoidMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; @@ -2351,6 +2354,7 @@ ObjectWrap::DefineClass(napi_env env, return {}; })); } else if (p.instanceMethod != nullptr) { + descriptor.setProperty(rt, "writable", jsi::Value{(p.attributes & napi_writable) != 0}); descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.instanceMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 94251e55..26108c70 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1323,7 +1323,8 @@ describe("URL.createObjectURL", function () { const url = URL.createObjectURL(real); expect(url.indexOf("blob:")).to.equal(0); URL.revokeObjectURL(url); - expect(() => URL.createObjectURL(new LookAlikeBlob() as any)).to.throw(TypeError, /not a Blob/); + // Message only: the JSI adapter still surfaces native throws as plain Errors. + expect(() => URL.createObjectURL(new LookAlikeBlob() as any)).to.throw(/not a Blob/); } finally { globalObject.Blob = NativeBlob; } From a5c341dd02df4217b03663fd095160efa0047c8e Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 16 Sep 2026 17:38:37 -0700 Subject: [PATCH 4/5] Blob: pin the constructor under a hidden global instead of the JsRuntime native object (workers have no JsRuntime) --- Polyfills/Blob/Source/Blob.cpp | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index f7b79099..876b65da 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -5,9 +5,15 @@ namespace Babylon::Polyfills::Internal { + namespace + { + constexpr auto JS_BLOB_CONSTRUCTOR_NAME = "Blob"; + // Hidden global holding the polyfill's own constructor (see TryGetData). + constexpr auto JS_BLOB_CONSTRUCTOR_KEY = "__jsRuntimeHostBlob"; + } + void Blob::Initialize(Napi::Env env) { - static constexpr auto JS_BLOB_CONSTRUCTOR_NAME = "Blob"; if (env.Global().Get(JS_BLOB_CONSTRUCTOR_NAME).IsUndefined()) { Napi::Function func = DefineClass( @@ -21,10 +27,15 @@ namespace Babylon::Polyfills::Internal InstanceMethod("bytes", &Blob::Bytes), }); - // TryGetData identifies our instances through this reference, so a script that later - // replaces the global `Blob` (a test shim, another polyfill) cannot make a foreign object - // look like one of ours. - JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_BLOB_CONSTRUCTOR_NAME, func); + // TryGetData identifies our instances through this hidden, non-writable, non-configurable + // global, so a script that later replaces the global `Blob` (a test shim, another + // polyfill) cannot make a foreign object look like one of ours. A plain global rather + // than the JsRuntime native object: worker environments have no JsRuntime. + auto constructorDescriptor = Napi::Object::New(env); + constructorDescriptor.Set("value", func); + env.Global().Get("Object").As().Get("defineProperty").As().Call( + env.Global().Get("Object"), + {env.Global(), Napi::String::New(env, JS_BLOB_CONSTRUCTOR_KEY), constructorDescriptor}); env.Global().Set(JS_BLOB_CONSTRUCTOR_NAME, func); } } @@ -160,9 +171,9 @@ namespace Babylon::Polyfills::Blob // This keeps the check portable across QuickJS, V8, JavaScriptCore, Chakra and JSI, and it // also accepts Blob subclasses (e.g. File). We deliberately avoid napi_instanceof, whose // node-addon-api wrapper requires a Napi::Function. - // Our constructor is the one registered at Initialize, not the global `Blob` binding: a - // script may have replaced that with its own class whose instances wrap nothing. - const auto blobConstructor = JsRuntime::NativeObject::GetFromJavaScript(env).Get("Blob"); + // Our constructor is the one Initialize pinned under the hidden key, not the global `Blob` + // binding: a script may have replaced that with its own class whose instances wrap nothing. + const auto blobConstructor = global.Get(JS_BLOB_CONSTRUCTOR_KEY); if (!blobConstructor.IsFunction()) { return std::nullopt; From 78a9a7faaf443a4e9b71eb7a8911471f56df56de Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 16 Sep 2026 17:39:19 -0700 Subject: [PATCH 5/5] Blob: hoist the constructor-key constants to file scope (fix the build) --- Polyfills/Blob/Source/Blob.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index 876b65da..dabf6f24 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -3,15 +3,15 @@ #include #include -namespace Babylon::Polyfills::Internal +namespace { - namespace - { - constexpr auto JS_BLOB_CONSTRUCTOR_NAME = "Blob"; - // Hidden global holding the polyfill's own constructor (see TryGetData). - constexpr auto JS_BLOB_CONSTRUCTOR_KEY = "__jsRuntimeHostBlob"; - } + constexpr auto JS_BLOB_CONSTRUCTOR_NAME = "Blob"; + // Hidden global holding the polyfill's own constructor (see Babylon::Polyfills::Blob::TryGetData). + constexpr auto JS_BLOB_CONSTRUCTOR_KEY = "__jsRuntimeHostBlob"; +} +namespace Babylon::Polyfills::Internal +{ void Blob::Initialize(Napi::Env env) { if (env.Global().Get(JS_BLOB_CONSTRUCTOR_NAME).IsUndefined())