-
Notifications
You must be signed in to change notification settings - Fork 23
Fix napi_get_property_names conformance on JSC, Chakra and QuickJS #218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2fac54e
9e3c304
3b857e7
c3def88
ad868a2
1767446
cbe9c16
0d7ab06
efc20cb
fdd36a9
26a4a99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| #include "js_native_api_shared.h" | ||
|
|
||
| #include <napi/js_native_api.h> | ||
|
|
||
| #include <string> | ||
| #include <unordered_set> | ||
| #include <vector> | ||
|
|
||
| namespace napi_shared { | ||
| namespace { | ||
| #define RETURN_IF_NOT_OK(expression) \ | ||
| do { \ | ||
| const napi_status status__{(expression)}; \ | ||
| if (status__ != napi_ok) { \ | ||
| return status__; \ | ||
| } \ | ||
| } while (0) | ||
|
|
||
| napi_status GetUtf8Value(napi_env env, napi_value value, std::string& result) { | ||
| size_t length{}; | ||
| RETURN_IF_NOT_OK(napi_get_value_string_utf8(env, value, nullptr, 0, &length)); | ||
|
|
||
| std::vector<char> buffer(length + 1); | ||
| size_t copied{}; | ||
| RETURN_IF_NOT_OK(napi_get_value_string_utf8(env, value, buffer.data(), buffer.size(), &copied)); | ||
|
|
||
| result.assign(buffer.data(), copied); | ||
| return napi_ok; | ||
| } | ||
|
|
||
| napi_status IsObjectLike(napi_env env, napi_value value, bool& result) { | ||
| napi_valuetype type{}; | ||
| RETURN_IF_NOT_OK(napi_typeof(env, value, &type)); | ||
| result = (type == napi_object || type == napi_function || type == napi_external); | ||
| return napi_ok; | ||
| } | ||
|
|
||
| // Appends every element of the string array `names` to `shadowed`. | ||
| napi_status AddAll(napi_env env, napi_value names, std::unordered_set<std::string>& shadowed) { | ||
| uint32_t count{}; | ||
| RETURN_IF_NOT_OK(napi_get_array_length(env, names, &count)); | ||
|
|
||
| std::string key{}; | ||
| for (uint32_t index = 0; index < count; ++index) { | ||
| napi_value name{}; | ||
| RETURN_IF_NOT_OK(napi_get_element(env, names, index, &name)); | ||
| RETURN_IF_NOT_OK(GetUtf8Value(env, name, key)); | ||
| shadowed.insert(std::move(key)); | ||
| } | ||
|
|
||
| return napi_ok; | ||
| } | ||
|
|
||
| // Whether `value` is strictly equal to something already in `seen`. | ||
| napi_status Contains(napi_env env, const std::vector<napi_value>& seen, napi_value value, bool& result) { | ||
| for (const napi_value candidate : seen) { | ||
| bool equal{}; | ||
| RETURN_IF_NOT_OK(napi_strict_equals(env, candidate, value, &equal)); | ||
| if (equal) { | ||
| result = true; | ||
| return napi_ok; | ||
| } | ||
| } | ||
|
|
||
| result = false; | ||
| return napi_ok; | ||
| } | ||
| } | ||
|
|
||
| napi_status GetEnumerablePropertyNames(napi_env env, napi_value object, napi_value* result) { | ||
| // `Object.keys` reports one level's own enumerable string-keyed properties | ||
| // in specification order, which is exactly what `for...in` visits at that | ||
| // level. `Object.getOwnPropertyNames` additionally reports the | ||
| // non-enumerable ones: `for...in` does not visit those, but they still | ||
| // shadow same-named properties further up the prototype chain, so they have | ||
| // to be tracked as well. | ||
| napi_value global{}; | ||
| napi_value objectConstructor{}; | ||
| napi_value keys{}; | ||
| napi_value getOwnPropertyNames{}; | ||
| RETURN_IF_NOT_OK(napi_get_global(env, &global)); | ||
| RETURN_IF_NOT_OK(napi_get_named_property(env, global, "Object", &objectConstructor)); | ||
| RETURN_IF_NOT_OK(napi_get_named_property(env, objectConstructor, "keys", &keys)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. grabbing global.Object.keys and Object.getOwnPropertyNames on every call allows user monkey-patching to change native Node-API behavior. if a BabylonNative app will be executing user-generated code, this is an attack surface. PR #116 already established the opposite invariant by capturing canonical Function.prototype.call. is there a reason not to use protected pristine intrinsics or engine-native key enumeration beyond sharing code? To pin this, I suggest adding a poisoned-intrinsics regression integration test. If you all have an internal fuzzer harness, you should ask why it didn't find/highlight this.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I looked into this one carefully and I am going to push back, though not on the premise. The premise is sound: script can redefine Where I disagree is that it is a property of this change. Two of the supporting points do not hold up. PR #116 has not "established the opposite invariant" -- it is still open and unmerged, so there is nothing to be consistent with yet. And "use engine-native key enumeration instead" is not available on JavaScriptCore: its public C API offers only So: worth doing, worth doing repo-wide against the whole intrinsic-dependent surface, and worth doing with a considered mechanism -- a per-env snapshot of the intrinsics taken at init, rather than ad-hoc lookups. That is a different change from this one, and I would rather it be scoped and reviewed on its own than smuggled in here. I am deliberately not filing an issue for it; if the team wants it, it should be prioritised as its own piece of work rather than parked in the backlog. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TL;DR: please merge #116 first, then this PR, then we/I can do a hardening pass across the repo to prevent prototype injection attacks. ok, I agree that the repo-wide pristine-intrinsic hardening can be handled as a separate PR, but I want to clarify two points after some cross-checking rather than leave misunderstandings for other folks/agents reading. First, you are correct that #116 has not merged. “Established” was imprecise wording on my part: I meant that #116 has already implemented the invariant I believe this project should adopt, and that I have deployed in my N-API modules in my BabylonNative app, not that the invariant is already present on main. The existing PR captures the canonical Function.prototype.call once during environment initialization so that user mutation cannot alter napi_call_function behavior, and it introduces the portable Node-API conformance harness. Doing work in N-APi without at least merging the conformance suite PR first will probably yield more friction as time goes on -- unless you and the other project maintainers are purposefully deviating from Node API conformance? I checked the JavaScriptCore claim because I was pretty sure that statement was incorrect. Looking at Apple’s public SDK (rather than WebKit SPI) and Bun's fork of JSC, JSObjectCopyPropertyNames and JSObjectGetPrototype are both public and available at my deployment floors—Apple currently lists iOS 16+ and macOS 10.5+. Looking at recent adjustments to CI, it looks like Microsoft's deployment floors are higher than that even? I do see that even the current public JSC API has no configurable own/prototype, enumerable, string/symbol, descriptor-aware enumeration operation equivalent to V8’s key-collection modes. I also verified the behavior against shipping JSC: given an enumerable inherited property shadowed by a non-enumerable own property, JSObjectCopyPropertyNames returns the inherited name while for...in correctly omits it. Therefore, it is not a conforming replacement for the shared walk. The shadowing wording above is slightly ambiguous: JSC does not drop the shadowed inherited property; it fails to let the non-enumerable own property suppress that inherited name. Also, #216 reported the existing JSC backend’s unconditional throw and the cross-backend prototype/enumerability differences; this shadowing case is useful additional conformance coverage discovered while fixing it. sorry for the wall of text, but you/agent got some things wrong and there were some nuances that are important to get full understanding on if we're going to do a good job of supporting N-API across the diversity of open souce JS VMs that's been expanded over the last several months (QuickJS, Hermes, etc).
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right on the shadowing point, and I've corrected it in 26a4a99 — thanks for pushing on it. I checked it against WebKit rather than just taking the correction: Two smaller clarifications, in the same spirit of not leaving misunderstandings behind: I didn't claim And no, there's no deliberate deviation from Node-API conformance — this PR is a conformance fix, and CI now covers the shadowing rule, symbol exclusion, coercion, prototype-chain ordering and Merge order for #116 is bghgary's call, not mine, so I'll leave that to him. I have no objection to it landing first; I'd just rather not block a fix for a reported bug on it. The two don't conflict — nothing here touches |
||
| RETURN_IF_NOT_OK(napi_get_named_property(env, objectConstructor, "getOwnPropertyNames", &getOwnPropertyNames)); | ||
|
|
||
| napi_value names{}; | ||
| RETURN_IF_NOT_OK(napi_create_array(env, &names)); | ||
| uint32_t nameCount{}; | ||
|
|
||
| std::unordered_set<std::string> shadowed{}; | ||
| std::vector<napi_value> visited{}; | ||
| std::string key{}; | ||
|
|
||
| // `ToObject` is what the specification (and the V8 implementation) applies | ||
| // to the argument, so a primitive is wrapped and its properties reported. | ||
| // `null` and `undefined` have no wrapper, and V8 reports that as | ||
| // `napi_object_expected`; check explicitly rather than relying on | ||
| // `napi_coerce_to_object`, whose behaviour for those two values differs | ||
| // between engines (QuickJS yields an empty object, JavaScriptCore throws). | ||
| napi_valuetype type{}; | ||
| RETURN_IF_NOT_OK(napi_typeof(env, object, &type)); | ||
| if (type == napi_null || type == napi_undefined) { | ||
| return napi_object_expected; | ||
| } | ||
|
|
||
| napi_value current{}; | ||
| RETURN_IF_NOT_OK(napi_coerce_to_object(env, object, ¤t)); | ||
|
|
||
| while (true) { | ||
| bool isObjectLike{}; | ||
| RETURN_IF_NOT_OK(IsObjectLike(env, current, isObjectLike)); | ||
| if (!isObjectLike) { | ||
| break; | ||
| } | ||
|
|
||
| // A `getPrototypeOf` Proxy trap can return an object that is already on | ||
| // the chain -- nothing in the specification forbids it, so | ||
| // `Object.getPrototypeOf(p) === p` is reachable from script -- which | ||
| // makes this walk cyclic. V8 recurses and so terminates with a | ||
| // `RangeError`; this loop is iterative and would spin forever. | ||
| // | ||
| // Stopping at the repeat is exact rather than a bail-out: every level | ||
| // adds its own property names to `shadowed` before the walk continues, | ||
| // so a level visited a second time can only re-encounter names that are | ||
| // already shadowed. Breaking here therefore yields the same result the | ||
| // non-terminating walk converges on. | ||
| bool alreadyVisited{}; | ||
| RETURN_IF_NOT_OK(Contains(env, visited, current, alreadyVisited)); | ||
| if (alreadyVisited) { | ||
| break; | ||
| } | ||
| visited.push_back(current); | ||
|
|
||
| napi_value ownEnumerableNames{}; | ||
| RETURN_IF_NOT_OK(napi_call_function(env, objectConstructor, keys, 1, ¤t, &ownEnumerableNames)); | ||
|
|
||
| uint32_t ownEnumerableCount{}; | ||
| RETURN_IF_NOT_OK(napi_get_array_length(env, ownEnumerableNames, &ownEnumerableCount)); | ||
| for (uint32_t index = 0; index < ownEnumerableCount; ++index) { | ||
| napi_value name{}; | ||
| RETURN_IF_NOT_OK(napi_get_element(env, ownEnumerableNames, index, &name)); | ||
| RETURN_IF_NOT_OK(GetUtf8Value(env, name, key)); | ||
| if (shadowed.find(key) == shadowed.end()) { | ||
| RETURN_IF_NOT_OK(napi_set_element(env, names, nameCount++, name)); | ||
| } | ||
| } | ||
|
|
||
| napi_value next{}; | ||
| RETURN_IF_NOT_OK(napi_get_prototype(env, current, &next)); | ||
|
|
||
| bool hasNextLevel{}; | ||
| RETURN_IF_NOT_OK(IsObjectLike(env, next, hasNextLevel)); | ||
| if (hasNextLevel) { | ||
| napi_value ownNames{}; | ||
| RETURN_IF_NOT_OK(napi_call_function(env, objectConstructor, getOwnPropertyNames, 1, ¤t, &ownNames)); | ||
| RETURN_IF_NOT_OK(AddAll(env, ownNames, shadowed)); | ||
| } | ||
|
|
||
| current = next; | ||
| } | ||
|
|
||
| *result = names; | ||
| return napi_ok; | ||
| } | ||
|
|
||
| #undef RETURN_IF_NOT_OK | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| #pragma once | ||
|
|
||
| #include <napi/js_native_api_types.h> | ||
|
|
||
| // Engine-agnostic pieces of the Node-API surface, implemented purely in terms | ||
| // of the public `napi_*` entry points so that every backend behaves the same. | ||
| // Backends whose engine offers a faithful native equivalent should keep using | ||
| // it; these helpers exist for the ones that do not. | ||
| namespace napi_shared { | ||
| // Implements `napi_get_property_names` semantics: the names of all | ||
| // enumerable string-keyed properties of `object` and of its prototype chain, | ||
| // as an array of strings, matching a `for...in` enumeration. | ||
| // | ||
| // V8 gets this from a single `GetPropertyNames` call configured with | ||
| // `kIncludePrototypes | ONLY_ENUMERABLE | SKIP_SYMBOLS`. JavaScriptCore, | ||
| // Chakra and QuickJS have no equivalent, so this walks the prototype | ||
| // chain explicitly. See https://github.com/BabylonJS/JsRuntimeHost/issues/216. | ||
|
bkaradzic-microsoft marked this conversation as resolved.
|
||
| // | ||
| // `object` is coerced with `napi_coerce_to_object`, as V8's `CHECK_TO_OBJECT` | ||
| // does. Callers are expected to have already validated `env` and `result`. | ||
| napi_status GetEnumerablePropertyNames(napi_env env, napi_value object, napi_value* result); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.