Prevent sealed APIs from being polluted

I’ve been part of a recent “storm” in the Node.js world after providing an example of Promise.prototype.then pollution that would allow any malicious code to intercept anything out there, especially crypto and its subtle related operations, which is (imho) a security no-go

Background

Native prototype pollution has been essential to move the Web forward and it’s a more than welcome feature, but in recent years most Web related APIs are Promise based and I feel like:

  • the Promise original API is frozen in time, as in … nobody expects then or catch to change anytime soon in the next thousand years
  • the crypto and its subtle entries are mostly based on Promise API: one leak/evil hook at that prototype, we’re all doomed
  • everything that travels asynchronously, including fetch operations, can be intercepted, instrumented, and poisoned in a way or another or, worst part, leak in the wild

It’s true that Promise is likely my only target for this proposal, but at the same time it’s the most compelling use case for such proposal.

Proposal

Find out what native API is sealed in time and widely available (close to 100% if not just 100% across vendors/engines) and decide that, if every environment would never ever need a specific patch for such API, as it’s legacy or not inherently leaking data, the descriptor signature out of that API is writable: false and configurable: false, covering also all accessors in the making (yes, in my report I’ve mentioned even TypedArray buffer access could be poisoned by reaching Uint8Array.__proto__.prototype and its buffer descriptor).

Goal

Allow developers to trust that if they await anything that anything resolved value can’t ever be intercepted by Promise.prototype.then pollution, because that pollution in the first place would throw an error out of the box.

This would increase security concerns about most asynchronous APIs already and make the Web, or in general JS as a language, a place to trust more, as opposite of being victim of any npm module out there that got hacked before the next production deployment.

Risk

There might be modules with honest intents able to provide metrics or whatnot that need to be able to wrap Promise#then or any other introspection Object or Reflect based method around, although that should not be the default allowed to any script, rather an ad-hoc environment (with its flags, like via playwright or puppeteer) so that the “production Web code” can run in a safer way that doesn’t need ugly/paranoia workaround to maintain.

Compromise

I think having crypto and its subtle impossible to hack around would be already a huge step forward, if asking to have some historical prototype sealed by any mean feels like too much, but when it comes to Promise I am not asking to seal the whole thing, imagine polyfills impossible to provide a finally hook, as example, although I think Promise.prototype.then and why not, catch, should not be defined just like everything else, as it’s clear the entirety of the Web is moving toward more asynchronous APIs, so that the risk somebody can poison the env at any time and intercept values out of it, or their classes such as CryptoKey, would be mitigated out of the box.

Personal Opinion

I am not sure I know any other scripting language (Ruby, Python, PHP, others) that can poison or pollute, beside being even Server Side targeting PLs, primitives offered by the language itself (stdClass in PHP, dict, list or tuple in Python and so on) so that keeping the dynamic nature of JS, as it is, should be a goal, but at the same time “something gotta be safe out there” and that something is Promise.prototype land together with (at least?) crypto and its subtle offer.

Thanks in advance for anyone willing to at least open a discussion around this topic.

1 Like

Previous Work

Forgot to mention that while anyone can sniff Array spread or for of loops iterations by polluting Symbol.iterator on the array prototype, the hidden Arguments class does not suffer the same issue, as an iterator method is directly attached, behind the scene, to it, so that it’s not affected by evil code.

That might be it to me, some namespace that attached the native, and secure/safe thing, to its prototype, just like internally browsers won’t leak builtin related operations, and I might be OK with this as resolution at least for crypto and subtle targets, but of course the Promise.prototype would make this amend/change/feature a wonder for the present and the future.

Pretty sure preventing promises from being tampered with would break every Angular app out there. Most Angular apps rely on zone.js to try and figure out when it needs to run it's change detection algorithm, and zone.js monkey-patches all sorts of things to help it watch what a component does.

Edit: said things slightly wrong before, fixed

As Angular moves towards a zoneless application development model, Zone.js is no longer accepting new features, including additional patches for native platform APIs. The team will also not be accepting any low priority bug fixes. Any critical bug fixes that relate to Angular's direct use of Zone.js will still be accepted.

While still a supported part of Angular, the Angular team strongly discourages using Zone.js outside of Angular application contexts.

I am not sure because some team decided to use global pathces to work (in 2025 … what a weird choice) and it’s now moving away from that approach (thankfully) the rest of the Web should be kept more problematic than it should be but thanks for pointing that out and happy to read that’s also … out

Yes, Angular has recently started providing an alternative option (signals), but it's a really huge lift to migrate an existing codebase to use their new signal API - there's going to be a very significant portion of the web that won't ever make that migration due to their projects having little to maintenance now. Plus, the migration to signals is meant to be optional, Angular is trying to remain backwards compatible (they've already had to deal with the backlash that happened between angular.js to current Angular).

It's one teams decision to make a framework rely on zone.js, but many, many teams decision to then use that framework (including my own workplace actually), and we're going to be stuck with that. My insurance provider’s website, I noticed, also seems to use Angular, and it's probably relying on zone.js (again, only recently did Angular provide an alternative option). I would really hate it if browsers decided to completely break that website until they do a full rewrite to signals. I would also really hate it if we were forced to do a full rewrite to signals as well - we still have angular.js code at our workplace that we haven't rewritten to Angular yet - we're working on it, but these sorts of things take a lot of work - (and so with those projects, we don't even have a signal based approach available to us). And some Angular projects are just unmaintained and will forever be using zone.js.

I am not sure because some team decided to use global pathces to work (in 2025 … what a weird choice) and it’s now moving away from that approach (thankfully) the rest of the Web should be kept more problematic than it should

This actually happens all the time. MooTools (when's the last time you heard of someone using that library?) decided to modify the prototype and add an array.flatten() method, and as a result, when an official flatten method came out,they had to instead call it .flat() to avoid conflict with MooTools. .flatten() had it off easy, the new group by method couldn't even live on the array prototype for similar reasons.

One team's decision on legacy tools effects the future of JavaScript quite a bit.

Unfortunately making Promise.prototype.then frozen at realm creation is likely to be Web incompatible. Besides use cases that actually require it to be mutable (however misguided these may be) it would likely trigger cases of override mistake in code that extend promises without class syntax, or modify promise instances (again however misguided that may be).

Some of us are big fan of hardening the intrinsics, and believe we should find a way to make it ergonomic for applications to opt into such a mode (after trusted shims have been applied). It would also be great if engines started better optimizing cases where objects are known to be immutable with primordial behavior. However I strongly believe any freezing of intrinsics should be opt-in.

Back to the promise case, I would love to hear more about the risks associated with hijacked Promise.prototype.then. In particular I do not believe alone it allows to interfere with a simple await. In that case, native promises are recognized and handled internally through PerformPromiseThen which does not trigger Promise.prototype.then. The only known reentrancy hazard for native promise instances is through the .constructor lookup performed during a PromiseResolve “species” check.

Of course .then lookups are a major reentrancy hazard when going through the promise resolution steps. We are actually looking at ways to let code (host or user) better protect itself from those hazards, but unfortunately this would have to be an explicit “safe” operation since changing the current .then lookup semantics is simply not web compatible.

For completeness, when doing return foo from an async function, unfortunately that path does go through the promise resolve steps that lookup and invoke .then

It's actually safer to do return await foo as that doesn't trigger the .then lookup if foo is a native promise.

However as any await it remains vulnerable to a pollution of the Promise.prototype.constructor: If you want to disable the “safe” fast path that await provides, simply do Promise.prototype.constructor = Promise.bind(null) and watch your Promise.prototype.then hijack trigger for a simple await Promise.resolve(42).

every crypto subtle operation to create keys can be intercepted by Promise.prototype.then pollution, as example, that’s the “storm” I’ve mentioned in the OP.

I wrote extremely defensive code that indeed trust internally PerformPromiseThen is used but even with my defensive code things didn’t go nearly as well as expected … without my guards, all leaks all over the place for something as delicate as crypto operations and its subtle were a piece of cake to intercept … meaning you track a CryptoKey instance, you have free access to anything that once encrypted can be decrypted.

This is some sort of horror show to me, but the fact any await ends up passing through then is beyond acceptable for some namespace, namely the crypto one, where I don’t want zone.js or any library that needs to patch globals (we’re not talking about polyfills in here, polyfills usually don’t even exist in the first place on those globals) and users can’t naturally write expectations without accidental leaks.

1 Like

P.S. also, Object.prototype.then enters the game, if you think native await could ever be safe for anything … this is really worrying to me, there’s free ride for any malicious code to make even crypto and/or security or sensitive based API exposed so while eval and any framework that in 2025 should be banned from using it can still use the power of eval and hope no CSP rule is around, the crypto namespace in particular should be as guarded as arguments[Symbol.iterator] is, with their own “impossible to hack” then resolvers (still imho).

It would be more likely for all the world’s oceans to freeze to Ice-9 than for the committee to decide to tackle an issue this size in a comprehensive way…

I always thought this is the problem the import mechanism was for. Wouldn't it fix the problem if scripts could get crypto function implementations directly from the engine without fear of monkey-patching?

If the analogy were Linux it’s like we have no clear boundary between kernelspace and userspace. With polyfills we often implement engine functionality with scripts. It doesn’t seem to me that this should necessarily be a problem, except that it isn’t the engine orchestrating the scripted extensions: that’s happening in the userspace layer. Because this pattern of userspace scripts like core-js filling engine functionality holds up the web in practice, the blurring of the user and system layers from a security standpoint is standard in the web as it exists today.

The only way I can imagine to fix such a problem is to put the system in charge of defining the system’s APIs (which it can implement in scripts if it wishes) and then allow userspace scripts to have direct/secure conversations with the system, as they would be able to if they could do something like import { sha256 } from 'js:WebCrypto'

1 Like

From what I understand of the issue you reported against Node.js, these crypto APIs are implemented in JS, and make some assumptions about the integrity of the JS Realm and its intrinsics.

It is indeed extremely difficult to program defensively against a pollution of the intrinsics, and while Node.js attempts to capture the primordials and use them as much as possible, given how unergonomic it is to program that way, there are bound to be mistakes made.

In the case of promise, the thenable protocol (and species behavior) is pretty pernicious. You’re correct that without changes to the language, it is likely impossible to be defensive against hijacks through pollution of the Promise prototype.

While I still believe that locking down the Realm by explicitly hardening all the intrinsics is the only defensible model, we should try to mitigate some of the cases. There have actually been a few related discussions recently at TC39:

  • Freezing the Array Iterator discussed on 2027-07-28
    I personally would prefer avoiding piece meal freezing of intrinsics at realm creation
  • How to make thenables safer? discussed most recently 2025-07
    The issue originally started as how to protect hosts (like browsers implementations) from unexpected user code executing through then pollutions, mostly vulnerabilities triggered by adding a Object.prototype.then getter.

To be clear, the trigger in that reproduction does not seem to be in your code await-ing a Promise produced by the crypto implementation, but instead is with how the internal crypto implementation handles its own promises. That is seemingly confirmed by the crypto implementation in other environments not being susceptible.

I want to re-iterate that await-ing a native promise (such as one from calling an async function), is not susceptible to a pollution limited to Promise.prototype.then.

That said, such pollution does affect return statements inside async functions, and it is still trivial to also pollute Promise.prototype.constructor to affect await expressions as well, so this is where the spec needs to change to address this.

IMO the following changes are needed:

  • Change handling of return completions in async functions to follow the await semantics of recognizing the native promise and extracting its resolution without going through the .then path. This is actually one of the goals of the Faster Promise Adoption proposal, and is hopefully web compatible.
  • Fix the promise "species" logic in PromiseResolve to avoid triggering any user code, or being susceptible to .constructor pollution. There is an open issue already to do this, related to the "safer thenable" discussions.
  • Some way of making objects unexpectedly thenable, in particular through Object.prototype.then pollution. I actually am really warming up to the idea of making Object.prototype more exotic so it cannot grow a then property.

Together these ensure that no unexpected code would execute when await-ing or return-ing a primitive value, non-thenable object or native base promise. User code would still execute for derived promises, and thenable objects (with a .then property anywhere on the prototype chain, except for the root Object.prototype). In particular, handling through syntax the promise created by an async function or other built-ins would not be susceptible to pollution of the intrinsics.

I'm also really hopeful we can get explicit "Safe Promise Capabilities", which would guarantee no synchronous reentrancy when handling possibly tainted objects. I don't know how much we can change existing Promise functions to recognize native promise instances instead of relying on the "thenable" protocol (the goal of the faster promise adoption proposal), but if not we could likely make that a part of the "Safe Promise Capabilities" approach.

Thanks a lot for all details, anything better than the current state would be a huge step forward to me so I’d welcome every single proposal/improvement you mentioned and I am happy this discussion wasn’t “just closed” as it happened in the mentioned thread.

Looking forward to read about / see progress around this topic :waving_hand: