Strong brand checking in JavaScript

I want to retract my previously stated opinion on this. It's true that tamper-proofing is a lot of work with minimal benefit to most end-users, and most libraries shouldn't worry about it. But, I do realize now that there are certainly valid use cases for writting this sort of code. For example, it would be really weird if monkey patching Array.prototype.map() suddenly caused node's http module to stop working. To prevent this, I understand that node writes all of their core modules in a tamper-proof way. Additioally, something like @ljharb's polyfills are intended to be as close to the spec as possible. It would, again, be weird if monkey patching Array.prototype.map cause another built-in method to start misbehaving because it was polyfilled in a way that wasn't tamper-resistent - the same reasons that node tamperproofs their code is the same reason @ljharb does so.

I've since learned of many other popular libraries who like to follow this practice. I do think it's a little overboard for most libraries to care enough about this issue to uglify their code to this extent, but hey, if that's what they want to do, so be it.

Just re-read this entire thread (that's 10 minutes I won't get back). It left me with a question. If getting a clean-room copy of the engine's API is so critical to certain applications, why is there not an ES API for doing so? I would have expected to see a proposal for this feature given the amount of effort that has been put into securing the language in this way. With such an API added to globalThis sealed and read only so as not to be tampered with, it would always be possible to access the engine-declared versions of the ES API.

I've actually toyed around with a similar idea. Having something like globalThis.stdLib on the API, where stdLib is the exact same thing as the initial state of globalThis, except for the fact that it's completly frozen.

There's a couple of issues I could think of with that.

  1. You can't polyfill it.

Though, if that's really something people care about, perhaps you could give the entry point special tampering permissions, to let them mess with stdLib before it gets frozen. The import assertions proposal can be used to pass this permission along to other modules. Or, something along those lines.

  1. More importantly: It doesn't actually help much.

This frozen stdLib object is a great resource when you want to, for example, use tamper-proofed static functions, like Math.max(). For anything that involves a class, it doesn't help a lot. For one, it would mean the Map class on globalThis.stdLib is a completely different class than the Map class on globalThis. If the end-user attempted to add their own custom protocol (e.g. some of their own symbols) to Map.prototype, they would expect that any maps you return from your library would have those symbols in the prototype, but they wouldn't. instanceof wouldn't work either (but that's not the most reliable thing anyways). These are all problems we already see when going cross-realm, but now they've been magnified. Someone like @ljharb wouldn't be able to use or return instance of globalThis.stdLib.Map in his polyfills, because those instances would cause his polyfills to behave differently from the spec's behavior.

Another issue is the fact that literals will still construct instances from globalThis, not globalThis.stdLib. So, you still can't do something like [2, 3, 4].map(...) and trust that it'll work.


On another note, if you're just trying to get a clean-room copy of a single function, and not the entire API, @ljharb is trying to put together a get-intrinsic proposal for exactly that over here. But, it's about the same amount of work to pick functions off of a prototype at the beginning of your code as it is to use this proposal, so its use case is a little more specialized than this.

With point 2, I think you missed something. The built-ins defined before userland code should all be identical by definition. If this new API returns that exact same set of built-ins, then the important part (their unmodified functionality) will be preserved and accessible. Isn't this the reason why so much effort is being burned on preserving the early state of critical API methods?

It's also not that the returned methods (in the case of classes) could be used directly like classes. You would indeed have to take a few extra steps. However, that's what's already being done. Needing the return from this API to be identical on an object level is outside the scope. However, having such a tamper-free, easily accessible version of the core API would give TC39 a place to put a brand checking API that meets @littledan & @ljharb requirements. There would always be a way to access the definitive version of unmodified brand checking methods.

What's more is that one such an API exists, it would be possible to create an equivalent for userland code. The approach would be to automatically catalog the original definition of a class at the point of declaration. It wouldn't even have the opportunity to modify itself (since there's no such thing as static constructors). For anything else to be added would require an explicit API call.

1 Like

Ok, your point makes sense. So, a frozen stdlib is useful, because that would allow you to get the original version of functions, not the version of functions that were available to you when you were first loaded.

This discussion did inspire me to create a new topic about trying to protect against globlalThis mutations. It goes a slightly different route, but it works, and it even makes brand-checking possible as a natural consequence of having access to the original APIs before they get overwritten.

That would break a ton of security-conscious environments that depend on being able to DENY access to builtins to code that runs after it.

1 Like

That's easily solved, even in a way that might make SES easier in this scenario. If the API included a function to restrict what can be returned by the built-in accessing function, then it would still be possible to deny access to various native functions in security-conscious environments while relaxed environments would get the privilege of not being hampered by potentially breaking monkey patches.

Then the first-run code (SES, in this case) wouldn't be able to keep using that API itself. Literally the only solution here is something replaceable, which is all described in the readme of https://github.com/tc39-transfer/proposal-get-intrinsic

1 Like

Necro-ing an old part of this thread, @ljharb, did you ever get anywhere on the following?

To clarify how this would work, internal operation would still do Get(O, @@toStringTag) but built-in objects would have a get %IntrinsicObject%.prototype[@@toStringTag] that performs a brand check, e.g. like get %TypedArray%.prototype [ @@toStringTag ] ?

Then a library could use Function.prototype.call.call(Object.getOwnPropertyDescriptor(IntrinsicObject.prototype, Symbol.toStringTag).get, target) to verify that the target is of the IntrinsicObject brand?

I believe that this would satisfy @markm's membrane transparency requirement as the normal usage would be target[Symbol.toStringTag], which would be wrapped by a membrane. Membranes have never claimed to be transparent if you have access to the unproxied original IntrinsicObject.

I never attempted to get anywhere on it, but yes, exactly that - we'd replace all the current string data properties under Symbol.toStringTag with a brand-checking getter function that returned a string.

The protocol would still Just Work with anything that provides a string, of course.

I agree with your membrane analysis.

Incorrect. Just look at a potential stub for this kind of function that I posted in a different thread.

The blocking function need only return originals of the functions it blocked. If that's not satisfactory, then just copy the references to the originating functions before blocking them. It's the same thing currently being done by modules wanting to deny other modules. The only difference is that there would be a way to do so provided by the engine, meaning it's no longer first come, first served. Instead, monkey patching and securing would be able to be handled separately.

That also forces it to be handled separately, which would break existing deployed code that depends on first-run code always being able to deny access (to deniable things) to later-run code. That is an axiom that can't ever be changed.

1 Like

I'm not sure I follow. Wouldn't first-run code still be able to deny access to later-run code through the API that @rdking proposed? Perhaps what "breaks" is the fact that these codebases need to go in and deny access via this API instead of monkey-patching if they want to be able to deny access to this newer getGlobal function @rdking proposed, but such a breaking change would happen whenever a new feature gets introduced that they want to prevent access to. Even in your getIntrinsic proposal @ljharb, everyone's existing codebases would "break" in the same way, in that they need to go in and monkeypatch gitIntrinsic too if they don't want later-run code to have access to certain globals.

The only difference I'm seeing is that the recommended way to deny access to certain globals is via an API instead of monkey patching (however, monkey patching would still work as before, it just couldn't be used on this getGlobal function if we chose to make it so it's readonly). Everything else seems to work the same. Am I missing something?

You're correct, except that these frameworks already delete any unrecognized globals - but they don't delete unrecognized Function.prototype properties, for example.

In other words, adding this new API as a global is viable; adding it as a prototype method is not. Additionally, being a global means it can be denied in a scope - being a prototype method means it could sneak its way in via any function that crosses a membrane boundary, which would break security invariants.

1 Like

In the way I thought of it, the new API would be namespaced, not a class to be instantiated. So no prototypes. There's nothing about this API that would even make it reasonable to make it a constructable object.

@rdking - could you maybe explain more in depth what you're proposing? I'm not entirely sure how it's different from @ljharb's proposal, except, I believe you're allowed to get entire objects back via your proposal, while @ljharb's proposal only lets you get individual functions? And, I assume you're wanting you make your new function readonly on globalThis (so it can't be deleted), which is why we're needing an alternative run-first system?

My proposal also lets you get entire objects, but the common use cases will be to get functions.

Basically nothing new can be readonly on globalThis ever again, for the reasons outlined.

1 Like

For the most part, @ljharb's proposal covers everything I was thinking when I made that suggestion. Giving it a read-only binding was just a suggestion to ensure that the management function itself could only be tampered with by calling it. But if there's some technical reason why that cannot be accomplished, I'm not too bent out of shape about it.

IMO, all of this is a very back-handed way of achieving what could be done by implementing 2 things in the language:

  • type fixing - to implement static typing without crippling dynamic typing.
  • API security - to provide a means of either denying and/or providing access to the intrinsic original ES API as well as any user-defined registered API.

Do those 2 things and you get:

  • user definable types
  • automatic type checking/enforcing
  • the ability to DENY any part of the ES API for subsequent users
  • the ability to access any ES API that has been monkey patched but not denied
  • strong brand checking (via types)
  • and a good reason to actually implement operator overloading.

I saw the proposal for operator overloading and couldn't help but think that it is utterly useless and nearly impossible to optimize without some way to enforce type checking. But that's just my 2 cents on that.

1 Like