Two important takeaways from CVE-2025-55182
We now have a public POC for CVE-2025-55182, the React Server vulnerability that allows remote code execution on affected servers. The details of how the exploit works are fascinating, and they highlight a couple of important but obscure facts about JavaScript itself that all JS developers should be aware of so that we hopefully don't make the same mistakes in our own code.
You may have heard that promises are not monads. This is because the then function is overloaded to act as both map and bind. For anyone without a functional programming background, here's a quick summary of what that means.
-
map, in the context of functional programming, is a function that takes an object containing one or more values, transforms each value to a new value using a given function, and automatically wraps the new value(s) in the same type of object as before.-
JavaScript's
Array.prototype.mapmethod is an example of thismapfunction for arrays. When called on an array of typeT[],mapaccepts a function(x: T) => Uand returns a new arrayU[]. -
Notably, if you return an array from the
mapcallback, you end up with an array of arrays.[1, 2, 3].map(x => [x]) // => [[1], [2], [3]]
-
-
bindis similar tomap, except the value returned by the given function must already be wrapped in the same type of object as the original object.-
If we pretend for a moment that the function passed to
Promise.prototype.thenmust always return a promise, thenPromise.prototype.thenbecomes a perfect example ofbind.Promise.resolve(4).then(x => Promise.resolve(x * 10)) // => Promise.resolve(40) Promise.resolve(4).then(x => Promise.reject('error')) // => Promise.reject('error') Promise.reject('error').then(x => Promise.resolve(x * 10)) // => Promise.reject('error')
-
Array.prototype.flatMapis also an example ofbind, provided that we pretend that the given function must always return an array.[1, 2, 3].flatMap(x => [x]) // => [1, 2, 3] [1, 2, 3].flatMap(x => [x, 0]) // => [1, 0, 2, 0, 3, 0]
-
But in reality, Promise.prototype.then (and indeed Array.prototype.flatMap) try to act as both map and bind at the same time.
Promise.resolve(4).then(x => x * 10) // => Promise.resolve(40)
Promise.resolve(4).then(x => Promise.resolve(x * 10)) // => Promise.resolve(40)JavaScript achieves this trick by checking if the value returned by the function passed to then is a "thenable". A thenable is any object that has a then property containing a function. When a thenable is returned from the function passed to the then method of a genuine promise (or when a thenable is awaited), the thenable's then function is called with arguments (resolve, reject), just like the Promise() constructor. The then function is expected to eventually call one of these arguments, at which point the outer then can resolve or reject accordingly.
Promise.resolve(4).then(x => ({ then: (resolve) => resolve(x * 40) })) // => Promise.resolve(40)This feature of JavaScript has a significant consequence when it comes to untrusted data. Consider a user-controlled object containing a function as its then property.
const userObj = { then: () => console.log('Hello, world!') }If this object were returned from any function passed to Promise.prototype.then, or from any function marked as async, it would call this then function.
Promise.resolve().then(() => userObj) // => 'Hello, world!' is logged to the console
async function getUserObj() {
return userObj
}
getUserObj() // => 'Hello, world!' is logged to the console
Promise.resolve(userObj) // => 'Hello, world!' is logged to the consoleIn each of these cases, in addition to logging Hello, world! to the console, a pending promise is returned. This is because userObj.then is called with (resolve, reject), but neither of these arguments are ever called. The resulting promise is permanently stuck in a pending state.
Even if the userObj.then function itself does nothing, introducing this permanently pending promise into the control flow could be used for denial of service, as it prevents JavaScript from garbage collecting any object referenced in code depending on the promise, as well as the promise itself.
The only way I can think of to avoid this scenario is to make absolutely sure that users can't create objects with arbitrary keys whose values are functions. But, as we're about to see, this isn't so straightforward.
It's worryingly common to see code that looks like this, where key and value are both variables.
obj[key] = valueOr even just:
const value = obj[key]In most programming languages, code like this would be harmless. But unfortunately, JavaScript is not like other programming languages.
JavaScript lets you do things like this:
obj['__proto__'] = { unexpectedKey: 'Hello, world!' }
obj.unexpectedKey // => 'Hello, world!'And this:
obj['constructor'] // => the Object() constructorAnd, as we saw in the POC for CVE-2025-55182, this:
obj['constructor']['constructor'] // => the Function() constructorThe Function() constructor is especially dangerous, since if you do this:
obj['constructor']['constructor']('alert(123)')You get () => alert(123). The POC used this trick to construct a user-defined function containing arbitrary code, which was then executed on the server. There were some intermediate steps involved, but all of them involved abusing three of JavaScript's special property keys, __proto__, constructor and then, to inject user-controlled data in places where the developers never expected it.
Even if an application isn't vulnerable to RCE, it's very easy to accidentally write code that, when combined with the thenable trick we saw above, can produce unintended results. For example, consider this code that copies an object property from key1 to key2, where both keys are controlled by the user, and then returns the resulting object from an async function.
async function copyProperties(key1, key2) {
const obj = {}
obj[key2] = obj[key1]
return obj
}If an attacker causes this function to be called with copyProperties('constructor', 'then'), this will result in an obj whose then property is obj['constructor'], which is the Object() constructor. Since typeof obj.then === 'function', obj is a thenable. When we try to return it from the async function, it calls the then function with (resolve, reject). Since neither of these arguments are ever called, we end up with a permanently pending promise.
copyProperties('constructor', 'then') // => pending promiseIt's possible to mitigate these risks by checking Object.hasOwn(obj, key) before attempting to read a user-specified key from an object, and by explicitly filtering out the keys __proto__, constructor and prototype before assigning to a user-specified key. However, it's very easy to forget to do so.
Instead, my recommendation is to avoid user-specified keys at all costs, regardless of whether you're assigning to them or just reading from them. Where possible, use a Map object instead, which can safely handle user-specified keys without requiring additional checks.
Great writeup! Thank you.