Skip to content

Instantly share code, notes, and snippets.

@12joan
Created December 5, 2025 12:35
Show Gist options
  • Select an option

  • Save 12joan/eb606fb5e5e1c44b0d9d2aacb04112f7 to your computer and use it in GitHub Desktop.

Select an option

Save 12joan/eb606fb5e5e1c44b0d9d2aacb04112f7 to your computer and use it in GitHub Desktop.
User-controlled Keys Considered Harmful - Two Important Takeaways from CVE-2025-55182

User-controlled Keys Considered Harmful

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.

1. Promises are overloaded

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.map method is an example of this map function for arrays. When called on an array of type T[], map accepts a function (x: T) => U and returns a new array U[].

    • Notably, if you return an array from the map callback, you end up with an array of arrays.

      [1, 2, 3].map(x => [x]) // => [[1], [2], [3]]
  • bind is similar to map, 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.then must always return a promise, then Promise.prototype.then becomes a perfect example of bind.

      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.flatMap is also an example of bind, 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 console

In 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.

2. User-controlled keys are evil

It's worryingly common to see code that looks like this, where key and value are both variables.

obj[key] = value

Or 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() constructor

And, as we saw in the POC for CVE-2025-55182, this:

obj['constructor']['constructor'] // => the Function() constructor

The 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 promise

Mitigation

It'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.

@Nyumat
Copy link

Nyumat commented Dec 6, 2025

Great writeup! Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment