From e471e83ee8aef8fa5142d5df5bd5c652bcf00115 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 1 Oct 2026 20:06:46 -0300 Subject: [PATCH 1/3] fix(runtime): unwrap Proxy receivers and arguments before native dispatch Native wrappers wrapped in a JS Proxy (e.g. by Vue's reactive()) expose no internal fields, so method calls were dispatched as class methods, property accessors returned undefined, toString returned an object, and passing a proxy as an argument aborted the process in GetCreationContext. - tns::UnwrapProxy walks Proxy::GetTarget chains; tns::GetValue resolves through it, so every wrapper lookup sees the target. - MethodCallback, property getter/setter and toString resolve a proxy receiver to its native target (or class constructor for methods) and throw a TypeError for revoked proxies or non-native targets. - WriteValue/WriteTypeValue/ToArray unwrap before marshalling and throw a TypeError for revoked proxies; ToObject and callback return values treat a revoked proxy as nil. - GetCreationContext call sites fall back to the current context instead of asserting when the object has none. --- NativeScript/runtime/AnimationFrame.mm | 2 +- NativeScript/runtime/ArgConverter.mm | 12 +- NativeScript/runtime/Helpers.h | 23 +++ NativeScript/runtime/Helpers.mm | 62 ++++++-- NativeScript/runtime/Interop.mm | 16 +- NativeScript/runtime/MetadataBuilder.mm | 73 ++++++++-- NativeScript/runtime/Reference.cpp | 4 - NativeScript/runtime/Timers.cpp | 2 +- TestRunner/app/tests/ProxyReceiverTests.js | 162 +++++++++++++++++++++ TestRunner/app/tests/index.js | 1 + 10 files changed, 315 insertions(+), 42 deletions(-) create mode 100644 TestRunner/app/tests/ProxyReceiverTests.js diff --git a/NativeScript/runtime/AnimationFrame.mm b/NativeScript/runtime/AnimationFrame.mm index 09994f395..43bb0b37a 100644 --- a/NativeScript/runtime/AnimationFrame.mm +++ b/NativeScript/runtime/AnimationFrame.mm @@ -225,7 +225,7 @@ void FireFrame(double timestampSeconds) { } entry->scheduled = false; Local cb = entry->callback.Get(isolate); - Local context = cb->GetCreationContextChecked(v8::Isolate::GetCurrent()); + Local context = tns::GetCreationContextOrCurrent(isolate, cb); Context::Scope contextScope(context); if (entry->raf) { Local argv[] = {v8::Number::New(isolate, performanceMillis)}; diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index d86749901..cdc251c0b 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -452,6 +452,9 @@ return; } + // Runs inside an ffi closure, where a C++ throw cannot propagate; a revoked + // proxy returns nil. + value = tns::UnwrapProxy(value); if (value.IsEmpty() || value->IsNullOrUndefined()) { void* nullPtr = nullptr; *(ffi_arg*)retValue = (unsigned long)nullPtr; @@ -647,7 +650,8 @@ std::vector> initializerArgs; std::string constructorTokens; if (info.Length() == 1 && info[0]->IsObject() && tns::GetValue(isolate, info[0]) == nullptr) { - initializerArgs = GetInitializerArgs(info[0].As(), constructorTokens); + Local initializer = tns::UnwrapProxyOrThrow(isolate, info[0]); + initializerArgs = GetInitializerArgs(initializer.As(), constructorTokens); } std::shared_ptr cache = Caches::Get(isolate); @@ -729,6 +733,8 @@ bool ArgConverter::CanInvoke(Local context, const TypeEncoding* typeEncoding, Local arg) { + // A revoked proxy matches anything so marshalling reports it as such. + arg = tns::UnwrapProxy(arg); if (arg.IsEmpty() || arg->IsNullOrUndefined()) { return true; } @@ -807,10 +813,8 @@ std::string& constructorTokens) { std::vector> args; constructorTokens = ""; - Local context; - bool success = obj->GetCreationContext(v8::Isolate::GetCurrent()).ToLocal(&context); - tns::Assert(success); Isolate* isolate = v8::Isolate::GetCurrent(); + Local context = tns::GetCreationContextOrCurrent(isolate, obj); Local properties; if (obj->GetOwnPropertyNames(context).ToLocal(&properties)) { std::stringstream ss; diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index 5b48ade53..1a70b1a33 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -258,7 +258,30 @@ void SetPrivateValue(const v8::Local& obj, const v8::Local GetPrivateValue(const v8::Local& obj, const v8::Local& propName); +// Follows a Proxy chain to its innermost target; empty when any link is +// revoked. Non-proxies come back unchanged. +inline v8::Local UnwrapProxy(v8::Local value) { + while (!value.IsEmpty() && value->IsProxy()) { + v8::Local proxy = value.As(); + if (proxy->IsRevoked()) { + return v8::Local(); + } + value = proxy->GetTarget(); + } + return value; +} + +// UnwrapProxy for values crossing into native code: a revoked proxy throws a +// NativeScriptException carrying a TypeError. +v8::Local UnwrapProxyOrThrow(v8::Isolate* isolate, v8::Local value); + +// The object's creation context, or the isolate's current (else main) context +// for objects that have none, such as proxies. +v8::Local GetCreationContextOrCurrent(v8::Isolate* isolate, + const v8::Local& obj); + void SetValue(v8::Isolate* isolate, const v8::Local& obj, BaseDataWrapper* value); +// Resolves through proxies: a proxied wrapper yields its target's wrapper. BaseDataWrapper* GetValue(v8::Isolate* isolate, const v8::Local& val); // What happens when JS touches a wrapper whose native counterpart has already diff --git a/NativeScript/runtime/Helpers.mm b/NativeScript/runtime/Helpers.mm index ef35ddaae..98d2493db 100644 --- a/NativeScript/runtime/Helpers.mm +++ b/NativeScript/runtime/Helpers.mm @@ -198,24 +198,46 @@ return ok; } +Local tns::UnwrapProxyOrThrow(Isolate* isolate, Local value) { + if (value.IsEmpty() || !value->IsProxy()) { + return value; + } + Local target = tns::UnwrapProxy(value); + if (target.IsEmpty()) { + std::string message = "Cannot pass a revoked Proxy to native code"; + throw NativeScriptException( + isolate, v8::Exception::TypeError(tns::ToV8String(isolate, message)), message); + } + return target; +} + +Local tns::GetCreationContextOrCurrent(Isolate* isolate, const Local& obj) { + Local context; + if (obj->GetCreationContext(isolate).ToLocal(&context)) { + return context; + } + context = isolate->GetCurrentContext(); + if (context.IsEmpty()) { + context = Caches::Get(isolate)->GetContext(); + } + return context; +} + void tns::SetPrivateValue(const Local& obj, const Local& propName, const Local& value) { - Local context; - bool success = obj->GetCreationContext(v8::Isolate::GetCurrent()).ToLocal(&context); - tns::Assert(success); Isolate* isolate = v8::Isolate::GetCurrent(); + Local context = tns::GetCreationContextOrCurrent(isolate, obj); Local privateKey = Private::ForApi(isolate, propName); + bool success = false; if (!obj->SetPrivate(context, privateKey, value).To(&success) || !success) { tns::Assert(false, isolate); } } Local tns::GetPrivateValue(const Local& obj, const Local& propName) { - Local context; - bool success = obj->GetCreationContext(v8::Isolate::GetCurrent()).ToLocal(&context); - tns::Assert(success); Isolate* isolate = v8::Isolate::GetCurrent(); + Local context = tns::GetCreationContextOrCurrent(isolate, obj); Local privateKey = Private::ForApi(isolate, propName); Maybe hasPrivate = obj->HasPrivate(context, privateKey); @@ -263,6 +285,13 @@ } Local obj = val.As(); + if (obj->IsProxy()) { + Local target = tns::UnwrapProxy(obj); + if (target.IsEmpty()) { + return nullptr; + } + obj = target.As(); + } if (obj->InternalFieldCount() > 0) { Local field = obj->GetInternalField(0).As(); if (field.IsEmpty() || field->IsNullOrUndefined() || !field->IsExternal()) { @@ -518,12 +547,10 @@ void WriteDebugLine(tns::LogCategory category, const char* message) { return; } - Local context; - bool success = obj->GetCreationContext(v8::Isolate::GetCurrent()).ToLocal(&context); - tns::Assert(success, isolate); + Local context = tns::GetCreationContextOrCurrent(isolate, obj); Local privateKey = Private::ForApi(isolate, metadataKey); - success = obj->DeletePrivate(context, privateKey).FromMaybe(false); + bool success = obj->DeletePrivate(context, privateKey).FromMaybe(false); tns::Assert(success, isolate); } @@ -537,18 +564,21 @@ void WriteDebugLine(tns::LogCategory category, const char* message) { } bool tns::IsArrayOrArrayLike(Isolate* isolate, const Local& value) { - if (value->IsArray()) { + Local target = tns::UnwrapProxy(value); + if (target.IsEmpty()) { + return false; + } + + if (target->IsArray()) { return true; } - if (!value->IsObject()) { + if (!target->IsObject()) { return false; } - Local obj = value.As(); - Local context; - bool success = obj->GetCreationContext(v8::Isolate::GetCurrent()).ToLocal(&context); - tns::Assert(success, isolate); + Local obj = target.As(); + Local context = tns::GetCreationContextOrCurrent(isolate, obj); return obj->Has(context, ToV8String(isolate, "length")).FromMaybe(false); } diff --git a/NativeScript/runtime/Interop.mm b/NativeScript/runtime/Interop.mm index 304535be6..dbc61dcc0 100644 --- a/NativeScript/runtime/Interop.mm +++ b/NativeScript/runtime/Interop.mm @@ -219,6 +219,7 @@ inline bool isBool() { void Interop::WriteTypeValue(Local context, BaseDataWrapper* typeWrapper, void* dest, Local arg) { Isolate* isolate = v8::Isolate::GetCurrent(); + arg = tns::UnwrapProxyOrThrow(isolate, arg); ValueCache argHelper(arg); bool isEmptyOrUndefined = arg.IsEmpty() || arg->IsNullOrUndefined(); bool success = false; @@ -258,6 +259,9 @@ inline bool isBool() { void Interop::WriteValue(Local context, const TypeEncoding* typeEncoding, void* dest, Local arg) { Isolate* isolate = v8::Isolate::GetCurrent(); + // Every branch below inspects the value's own type and internal fields, + // which a Proxy hides. + arg = tns::UnwrapProxyOrThrow(isolate, arg); ExecuteWriteValueDebugValidationsIfInDebug(context, typeEncoding, dest, arg); ValueCache argHelper(arg); if (arg.IsEmpty() || arg->IsNullOrUndefined()) { @@ -729,6 +733,9 @@ inline bool isBool() { id Interop::ToObject(Local context, v8::Local arg) { Isolate* isolate = v8::Isolate::GetCurrent(); + // Runs inside adapter callbacks invoked by native code, where a C++ throw + // cannot propagate; a revoked proxy reads as nil. + arg = tns::UnwrapProxy(arg); if (arg.IsEmpty() || arg->IsNullOrUndefined()) { return nil; } else if (tns::IsString(arg)) { @@ -1559,14 +1566,13 @@ inline bool isBool() { } Local Interop::ToArray(Local object) { + Isolate* isolate = v8::Isolate::GetCurrent(); + object = tns::UnwrapProxyOrThrow(isolate, object).As(); if (object->IsArray()) { return object.As(); } - Local context; - bool success = object->GetCreationContext(v8::Isolate::GetCurrent()).ToLocal(&context); - tns::Assert(success); - Isolate* isolate = v8::Isolate::GetCurrent(); + Local context = tns::GetCreationContextOrCurrent(isolate, object); Local sliceFunc; auto cache = Caches::Get(isolate); @@ -1596,7 +1602,7 @@ inline bool isBool() { Local sliceArgs[1]{object}; Local result; - success = sliceFunc->Call(context, object, 1, sliceArgs).ToLocal(&result); + bool success = sliceFunc->Call(context, object, 1, sliceArgs).ToLocal(&result); tns::Assert(success, isolate); return result.As(); diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index 604e4933e..194d895f8 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -19,6 +19,42 @@ namespace tns { +namespace { + +// Swaps a Proxy receiver for the native object at the end of its chain, so the +// call dispatches as if made on that object directly. On failure a TypeError is +// thrown and false returned. `allowClass` admits a class constructor target. +bool ResolveProxyReceiver(Isolate* isolate, Local& receiver, const char* action, + const char* memberName, bool allowClass) { + if (!receiver->IsProxy()) { + return true; + } + + Local target = tns::UnwrapProxy(receiver); + const char* reason = nullptr; + if (target.IsEmpty()) { + reason = "on a revoked Proxy"; + } else if (!(allowClass && target->IsFunction())) { + BaseDataWrapper* wrapper = + target.As()->InternalFieldCount() > 0 ? tns::GetValue(isolate, target) : nullptr; + if (wrapper == nullptr || (wrapper->Type() != WrapperType::ObjCObject && + wrapper->Type() != WrapperType::ObjCAllocObject)) { + reason = "on a Proxy whose target is not a native object"; + } + } + + if (reason != nullptr) { + std::string message = std::string("Cannot ") + action + " '" + memberName + "' " + reason; + isolate->ThrowException(Exception::TypeError(tns::ToV8String(isolate, message))); + return false; + } + + receiver = target.As(); + return true; +} + +} // namespace + void MetadataBuilder::RegisterConstantsOnGlobalObject(Isolate* isolate, Local globalTemplate, bool isWorkerThread) { @@ -251,14 +287,14 @@ throw NativeScriptException( Local context = isolate->GetCurrentContext(); tns::Assert(info.Length() == 2, isolate); - Local arg1 = info[0].As(); - Local arg2 = info[1].As(); - - if (arg1.IsEmpty() || !arg1->IsObject() || arg1->IsNullOrUndefined() || arg2.IsEmpty() || - !arg2->IsObject() || arg2->IsNullOrUndefined()) { + Local value1 = tns::UnwrapProxy(info[0]); + Local value2 = tns::UnwrapProxy(info[1]); + if (value1.IsEmpty() || !value1->IsObject() || value2.IsEmpty() || !value2->IsObject()) { info.GetReturnValue().Set(false); return; } + Local arg1 = value1.As(); + Local arg2 = value2.As(); BaseDataWrapper* wrapper = tns::GetValue(isolate, info.This()); if (wrapper == nullptr || wrapper->Type() != WrapperType::StructType) { @@ -472,6 +508,9 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta void MetadataBuilder::ToStringFunctionCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); Local thiz = info.This(); + if (!ResolveProxyReceiver(isolate, thiz, "call native method", "toString", false)) { + return; + } BaseDataWrapper* wrapper = tns::GetValue(isolate, thiz); if (wrapper == nullptr || wrapper->Type() != WrapperType::ObjCObject) { @@ -764,7 +803,12 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta CacheItem* item = static_cast*>( info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); - bool instanceMethod = info.This()->InternalFieldCount() > 0; + Local thiz = info.This(); + if (!ResolveProxyReceiver(isolate, thiz, "call native method", item->meta_->jsName(), true)) { + return; + } + + bool instanceMethod = thiz->InternalFieldCount() > 0; V8FunctionCallbackArgs args(info); // Only the class-side call rewrites the name, so the common path reads @@ -772,7 +816,6 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta const std::string* className = &item->className_; std::string classWrapperName; - Local thiz = info.This(); if (thiz->IsFunction()) { if (BaseDataWrapper* wrapper = tns::GetValue(isolate, thiz)) { ObjCClassWrapper* classWrapper = static_cast(wrapper); @@ -784,7 +827,7 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta Local context = isolate->GetCurrentContext(); Local result = instanceMethod - ? MetadataBuilder::InvokeMethod(context, item->meta_, info.This(), args, *className, true) + ? MetadataBuilder::InvokeMethod(context, item->meta_, thiz, args, *className, true) : MetadataBuilder::InvokeMethod(context, item->meta_, Local(), args, *className, true); @@ -794,15 +837,19 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta } void MetadataBuilder::PropertyGetterCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + CacheItem* item = static_cast*>( + info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); Local receiver = info.This(); + if (!ResolveProxyReceiver(isolate, receiver, "read native property", item->meta_->jsName(), + false)) { + return; + } if (receiver->InternalFieldCount() < 1) { return; } - Isolate* isolate = info.GetIsolate(); - CacheItem* item = static_cast*>( - info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); if (!item->meta_->hasGetter()) { Local error = Exception::Error(tns::ToV8String(isolate, "Property is not readable.")); isolate->ThrowException(error); @@ -830,6 +877,10 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta } Local receiver = info.This(); + if (!ResolveProxyReceiver(isolate, receiver, "set native property", item->meta_->jsName(), + false)) { + return; + } Local value = info[0]; V8SimpleValueArgs args(value); Local context = isolate->GetCurrentContext(); diff --git a/NativeScript/runtime/Reference.cpp b/NativeScript/runtime/Reference.cpp index 316326c59..d87096d13 100644 --- a/NativeScript/runtime/Reference.cpp +++ b/NativeScript/runtime/Reference.cpp @@ -397,10 +397,6 @@ void Reference::RegisterToStringMethod(Local context, } Reference::DataPair Reference::GetDataPair(Local obj) { - Local context; - bool success = - obj->GetCreationContext(v8::Isolate::GetCurrent()).ToLocal(&context); - tns::Assert(success); Isolate* isolate = v8::Isolate::GetCurrent(); BaseDataWrapper* wrapper = tns::GetValueOrReport(isolate, obj, "Reference indexed access"); diff --git a/NativeScript/runtime/Timers.cpp b/NativeScript/runtime/Timers.cpp index 773746fd5..353fd5876 100644 --- a/NativeScript/runtime/Timers.cpp +++ b/NativeScript/runtime/Timers.cpp @@ -207,7 +207,7 @@ class TimerState : public OrderedTaskSource { v8::Local cb = task->callback_.Get(isolate); v8::Local context = - cb->GetCreationContextChecked(v8::Isolate::GetCurrent()); + tns::GetCreationContextOrCurrent(isolate, cb); Context::Scope context_scope(context); int argc = task->args_ ? static_cast(task->args_->size()) : 0; if (argc > 0) { diff --git a/TestRunner/app/tests/ProxyReceiverTests.js b/TestRunner/app/tests/ProxyReceiverTests.js new file mode 100644 index 000000000..9a90d02ea --- /dev/null +++ b/TestRunner/app/tests/ProxyReceiverTests.js @@ -0,0 +1,162 @@ +describe(module.id, function () { + function findAccessor(proto, name) { + while (proto) { + var descriptor = Object.getOwnPropertyDescriptor(proto, name); + if (descriptor) { + return descriptor; + } + proto = Object.getPrototypeOf(proto); + } + return undefined; + } + + it("calls an instance method on the proxy target", function () { + var target = NSMutableString.alloc().init(); + var proxy = new Proxy(target, {}); + + proxy.appendString("abc"); + + expect(target.toString()).toBe("abc"); + expect(proxy.length).toBe(3); + }); + + it("calls an instance method through nested proxies", function () { + var target = NSMutableString.alloc().init(); + var proxy = new Proxy(new Proxy(target, {}), {}); + + proxy.appendString("ab"); + proxy.appendString("c"); + + expect(target.toString()).toBe("abc"); + }); + + it("reads a native property through the proxy", function () { + var target = NSMutableArray.alloc().init(); + target.addObject(1); + target.addObject(2); + var proxy = new Proxy(target, {}); + + expect(proxy.count).toBe(2); + }); + + it("writes a native property through the proxy", function () { + var target = NSOperation.alloc().init(); + var proxy = new Proxy(target, {}); + + proxy.name = "proxied"; + + expect(target.name).toBe("proxied"); + expect(proxy.name).toBe("proxied"); + }); + + it("converts the proxy to the native description", function () { + var target = NSMutableString.stringWithString("hello"); + var proxy = new Proxy(target, {}); + + expect(String(proxy)).toBe("hello"); + expect("" + proxy).toBe("hello"); + expect(proxy.toString()).toBe("hello"); + }); + + it("calls a class method through a proxied class constructor", function () { + var proxy = new Proxy(NSString, {}); + + var result = proxy.stringWithString("x"); + + expect(result instanceof NSString).toBe(true); + expect(result.toString()).toBe("x"); + }); + + it("passes a proxied native object as a method argument", function () { + var array = NSMutableArray.alloc().init(); + var object = NSObject.alloc().init(); + + array.addObject(new Proxy(object, {})); + + expect(array.count).toBe(1); + expect(array.objectAtIndex(0)).toBe(object); + }); + + it("passes a proxied JS array of native objects as an NSArray", function () { + var a = NSObject.alloc().init(); + var b = NSObject.alloc().init(); + + var array = NSArray.arrayWithArray(new Proxy([a, b], {})); + + expect(array.count).toBe(2); + expect(array.objectAtIndex(0)).toBe(a); + expect(array.objectAtIndex(1)).toBe(b); + }); + + it("passes a proxied plain object as an NSDictionary", function () { + var dictionary = NSDictionary.dictionaryWithDictionary(new Proxy({ key: "value" }, {})); + + expect(dictionary.count).toBe(1); + expect(dictionary.objectForKey("key")).toBe("value"); + }); + + it("passes proxied structs and struct initializers by value", function () { + var fromStruct = NSValue.valueWithRange(new Proxy(NSMakeRange(1, 2), {})); + expect(fromStruct.rangeValue.location).toBe(1); + expect(fromStruct.rangeValue.length).toBe(2); + + var fromObject = NSValue.valueWithRange(new Proxy({ location: 3, length: 4 }, {})); + expect(fromObject.rangeValue.location).toBe(3); + expect(fromObject.rangeValue.length).toBe(4); + }); + + it("throws a TypeError for a revoked proxy receiver", function () { + var revocable = Proxy.revocable(NSMutableString.alloc().init(), {}); + revocable.revoke(); + + expect(function () { + NSMutableString.prototype.appendString.call(revocable.proxy, "x"); + }).toThrowError(TypeError, /revoked Proxy/); + expect(function () { + findAccessor(NSMutableString.prototype, "length").get.call(revocable.proxy); + }).toThrowError(TypeError, /revoked Proxy/); + }); + + it("throws a TypeError for a revoked proxy argument", function () { + var array = NSMutableArray.alloc().init(); + var revocable = Proxy.revocable(NSObject.alloc().init(), {}); + revocable.revoke(); + + expect(function () { + array.addObject(revocable.proxy); + }).toThrowError(TypeError, /revoked Proxy/); + expect(array.count).toBe(0); + }); + + it("throws a TypeError for a proxy whose target is not a native object", function () { + var proxy = new Proxy({}, {}); + + expect(function () { + findAccessor(NSString.prototype, "length").get.call(proxy); + }).toThrowError(TypeError, /not a native object/); + expect(function () { + NSMutableString.prototype.appendString.call(proxy, "x"); + }).toThrowError(TypeError, /not a native object/); + expect(function () { + findAccessor(NSOperation.prototype, "name").set.call(proxy, "x"); + }).toThrowError(TypeError, /not a native object/); + }); + + it("consults proxy traps for the lookup and calls the target natively", function () { + var target = NSMutableString.alloc().init(); + var accessed = []; + var proxy = new Proxy(target, { + get: function (obj, key, receiver) { + accessed.push(key); + return Reflect.get(obj, key, receiver); + } + }); + + proxy.appendString("abc"); + + expect(accessed).toContain("appendString"); + expect(target.toString()).toBe("abc"); + expect(proxy.length).toBe(3); + expect(accessed).toContain("length"); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 31bfe10a5..86a4e26ad 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -128,6 +128,7 @@ require("./ObjCConstructors"); require("./MetadataTests"); // require("./ApiTests"); +require("./ProxyReceiverTests"); require("./NsRuntimeTests"); require("./GCFinalizerTests"); require("./WorkerConcurrentStartupTests"); From c4df088672223ba33e0dc756fd0ffe64c328cac5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 5 Oct 2026 16:42:57 -0300 Subject: [PATCH 2/3] fix(runtime): key wrapper state by the Proxy target in SetValue/DeleteValue GetValue resolves through proxies while SetValue and DeleteValue addressed the object they were handed, so __releaseNativeCounterpart(proxy) deleted the target's wrapper and then cleared the slot on the Proxy, leaving the target's internal field dangling. All three now resolve to the same target. ResolveProxyReceiver accepts a function target only when it carries an ObjCClass wrapper; a proxied plain function is a TypeError instead of a static call on the metadata class. --- NativeScript/runtime/Helpers.h | 3 ++- NativeScript/runtime/Helpers.mm | 17 +++++++++++++---- NativeScript/runtime/MetadataBuilder.mm | 13 ++++++++----- NativeScript/runtime/ObjectManager.mm | 4 +++- TestRunner/app/tests/ProxyReceiverTests.js | 17 +++++++++++++++++ 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index 1a70b1a33..75c3675a0 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -280,8 +280,9 @@ v8::Local UnwrapProxyOrThrow(v8::Isolate* isolate, v8::Local GetCreationContextOrCurrent(v8::Isolate* isolate, const v8::Local& obj); +// Set/Get/DeleteValue all resolve through proxies: wrapper state is keyed by +// the Proxy target, so a proxied wrapper yields its target's wrapper. void SetValue(v8::Isolate* isolate, const v8::Local& obj, BaseDataWrapper* value); -// Resolves through proxies: a proxied wrapper yields its target's wrapper. BaseDataWrapper* GetValue(v8::Isolate* isolate, const v8::Local& val); // What happens when JS touches a wrapper whose native counterpart has already diff --git a/NativeScript/runtime/Helpers.mm b/NativeScript/runtime/Helpers.mm index 98d2493db..37d1e2c28 100644 --- a/NativeScript/runtime/Helpers.mm +++ b/NativeScript/runtime/Helpers.mm @@ -265,11 +265,19 @@ throw NativeScriptException( return false; } -void tns::SetValue(Isolate* isolate, const Local& obj, BaseDataWrapper* value) { - if (obj.IsEmpty() || obj->IsNullOrUndefined()) { +void tns::SetValue(Isolate* isolate, const Local& val, BaseDataWrapper* value) { + if (val.IsEmpty() || val->IsNullOrUndefined()) { return; } + // Wrapper state lives on the Proxy target so that it is found by the same + // identity GetValue resolves to. + Local target = tns::UnwrapProxy(val); + if (target.IsEmpty()) { + return; + } + Local obj = target.As(); + Local ext = External::New(isolate, value, v8::kExternalPointerTypeTagDefault); if (obj->InternalFieldCount() > 0) { @@ -531,11 +539,12 @@ void WriteDebugLine(tns::LogCategory category, const char* message) { } void tns::DeleteValue(Isolate* isolate, const Local& val) { - if (val.IsEmpty() || val->IsNullOrUndefined() || !val->IsObject()) { + Local target = tns::UnwrapProxy(val); + if (target.IsEmpty() || target->IsNullOrUndefined() || !target->IsObject()) { return; } - Local obj = val.As(); + Local obj = target.As(); if (obj->InternalFieldCount() > 0) { obj->SetInternalField(0, v8::Undefined(isolate)); return; diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index 194d895f8..9a6bc945a 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -34,11 +34,14 @@ bool ResolveProxyReceiver(Isolate* isolate, Local& receiver, const char* const char* reason = nullptr; if (target.IsEmpty()) { reason = "on a revoked Proxy"; - } else if (!(allowClass && target->IsFunction())) { - BaseDataWrapper* wrapper = - target.As()->InternalFieldCount() > 0 ? tns::GetValue(isolate, target) : nullptr; - if (wrapper == nullptr || (wrapper->Type() != WrapperType::ObjCObject && - wrapper->Type() != WrapperType::ObjCAllocObject)) { + } else { + BaseDataWrapper* wrapper = tns::GetValue(isolate, target); + bool isClass = allowClass && target->IsFunction() && wrapper != nullptr && + wrapper->Type() == WrapperType::ObjCClass; + bool isInstance = target.As()->InternalFieldCount() > 0 && wrapper != nullptr && + (wrapper->Type() == WrapperType::ObjCObject || + wrapper->Type() == WrapperType::ObjCAllocObject); + if (!isClass && !isInstance) { reason = "on a Proxy whose target is not a native object"; } } diff --git a/NativeScript/runtime/ObjectManager.mm b/NativeScript/runtime/ObjectManager.mm index 013cf18f8..2791ef362 100644 --- a/NativeScript/runtime/ObjectManager.mm +++ b/NativeScript/runtime/ObjectManager.mm @@ -350,7 +350,9 @@ void DisposeHandle(v8::Isolate* isolate, return; } - Local value = info[0]; + // The lookup, the Instances key and the final SetValue must all address the + // same object, so a Proxy is resolved once here. + Local value = tns::UnwrapProxy(info[0]); BaseDataWrapper* wrapper = tns::GetValue(isolate, value); if (wrapper == nullptr) { diff --git a/TestRunner/app/tests/ProxyReceiverTests.js b/TestRunner/app/tests/ProxyReceiverTests.js index 9a90d02ea..21154e0b8 100644 --- a/TestRunner/app/tests/ProxyReceiverTests.js +++ b/TestRunner/app/tests/ProxyReceiverTests.js @@ -140,6 +140,23 @@ describe(module.id, function () { expect(function () { findAccessor(NSOperation.prototype, "name").set.call(proxy, "x"); }).toThrowError(TypeError, /not a native object/); + expect(function () { + NSString.stringWithString.call(new Proxy(function () {}, {}), "x"); + }).toThrowError(TypeError, /not a native object/); + }); + + it("releases the native counterpart of the proxy target", function () { + var target = NSMutableString.alloc().init(); + var proxy = new Proxy(target, {}); + + __releaseNativeCounterpart(proxy); + + expect(function () { + __releaseNativeCounterpart(target); + }).toThrowError(/not a native wrapper/); + expect(function () { + __releaseNativeCounterpart(proxy); + }).toThrowError(/not a native wrapper/); }); it("consults proxy traps for the lookup and calls the target natively", function () { From 8e35998230a2d2c35958233208ef06a594258cbf Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 6 Oct 2026 14:30:39 -0300 Subject: [PATCH 3/3] fix(runtime): zero the whole return slot for empty callback results ArgConverter::SetValue wrote a single ffi_arg when a JS callback returned null, undefined or a revoked Proxy, so struct-valued returns handed native code stale bytes past the first word. Callers now pass cif->rtype->size and the empty branch clears the full slot. --- NativeScript/runtime/ArgConverter.h | 4 +++- NativeScript/runtime/ArgConverter.mm | 13 +++++++------ NativeScript/runtime/ClassBuilder.mm | 2 +- NativeScript/runtime/MetadataBuilder.mm | 2 +- TestRunner/app/tests/ProxyReceiverTests.js | 17 +++++++++++++++++ 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/NativeScript/runtime/ArgConverter.h b/NativeScript/runtime/ArgConverter.h index 5e918109c..db3cd7c65 100644 --- a/NativeScript/runtime/ArgConverter.h +++ b/NativeScript/runtime/ArgConverter.h @@ -70,8 +70,10 @@ class ArgConverter { static const ProtocolMeta* FindProtocolMeta(Protocol* protocol); static void MethodCallback(ffi_cif* cif, void* retValue, void** argValues, void* userData); + // returnSize is the ABI size of the return slot (cif->rtype->size); an + // empty, null or undefined value zeroes all of it. static void SetValue(v8::Local context, void* retValue, - v8::Local value, + size_t returnSize, v8::Local value, const TypeEncoding* typeEncoding); // Returns (lazily creating) the per-isolate interop.escapeException brand // private stored in Caches. Empty handle if the isolate cache is invalid. diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index cdc251c0b..9b36b3f22 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -1,5 +1,6 @@ #include "ArgConverter.h" #include +#include #include #include "DictionaryAdapter.h" #include "Helpers.h" @@ -434,7 +435,7 @@ if (!success) { memset(retValue, 0, cif->rtype->size); } else { - ArgConverter::SetValue(context, retValue, result, data->typeEncoding_); + ArgConverter::SetValue(context, retValue, cif->rtype->size, result, data->typeEncoding_); } } else { memset(retValue, 0, cif->rtype->size); @@ -446,18 +447,18 @@ } } -void ArgConverter::SetValue(Local context, void* retValue, Local value, - const TypeEncoding* typeEncoding) { +void ArgConverter::SetValue(Local context, void* retValue, size_t returnSize, + Local value, const TypeEncoding* typeEncoding) { if (typeEncoding->type == BinaryTypeEncodingType::VoidEncoding) { return; } // Runs inside an ffi closure, where a C++ throw cannot propagate; a revoked - // proxy returns nil. + // proxy returns nil. libffi sizes the return slot to at least ffi_arg even + // for narrower types, and the full slot must be written. value = tns::UnwrapProxy(value); if (value.IsEmpty() || value->IsNullOrUndefined()) { - void* nullPtr = nullptr; - *(ffi_arg*)retValue = (unsigned long)nullPtr; + memset(retValue, 0, std::max(returnSize, sizeof(ffi_arg))); return; } diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 5b91128ba..ce792f4aa 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -931,7 +931,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { memset(retValue, 0, cif->rtype->size); } else { const TypeEncoding* typeEncoding = context->meta_->getter()->encodings()->first(); - ArgConverter::SetValue(v8Context, retValue, res, typeEncoding); + ArgConverter::SetValue(v8Context, retValue, cif->rtype->size, res, typeEncoding); } } if (pendingThrow != nil) { diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index 9a6bc945a..3e3651bd6 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -1220,7 +1220,7 @@ CMethodCall methodCall(context, item->userData_, typeEncoding, args, memset(retValue, 0, cif->rtype->size); } else { const TypeEncoding* typeEncoding = context->meta_->getter()->encodings()->first(); - ArgConverter::SetValue(v8Context, retValue, res, typeEncoding); + ArgConverter::SetValue(v8Context, retValue, cif->rtype->size, res, typeEncoding); } } if (pendingThrow != nil) { diff --git a/TestRunner/app/tests/ProxyReceiverTests.js b/TestRunner/app/tests/ProxyReceiverTests.js index 21154e0b8..e0cdfac86 100644 --- a/TestRunner/app/tests/ProxyReceiverTests.js +++ b/TestRunner/app/tests/ProxyReceiverTests.js @@ -145,6 +145,23 @@ describe(module.id, function () { }).toThrowError(TypeError, /not a native object/); }); + it("zeroes a struct return when a callback returns a revoked proxy", function () { + var revocable = Proxy.revocable(CGRectMake(1, 2, 3, 4), {}); + revocable.revoke(); + + [revocable.proxy, null].forEach(function (returned) { + var rect = getStructFromCallback(new interop.FunctionReference(function () { + return returned; + })); + + expect(rect instanceof CGRect).toBe(true); + expect(rect.origin.x).toBe(0); + expect(rect.origin.y).toBe(0); + expect(rect.size.width).toBe(0); + expect(rect.size.height).toBe(0); + }); + }); + it("releases the native counterpart of the proxy target", function () { var target = NSMutableString.alloc().init(); var proxy = new Proxy(target, {});