Skip to content

General re-entrancy problem when handling promise-like objects #3

@mhofman

Description

@mhofman

During the plenary I expressed my wish to explore the more general problem of risk of reentrancy when handling promise-like objects,

I believe all security issues are in fact rooted in this problem, and handling an object that is unexpectedly a thenable is only one of the manifestations. While unexpected thenables can also cause confusion and unexpected result values (e.g. thenable modules dynamically imported), I don't believe these would cause security issue if it the implicit promise resolve mechanism guarded against reentrancy.

Reentrancy in promise resolution

Overview of spec operations

The PromiseResolve(C, x) steps:

  1. If IsPromise(x) is true, then
    a. Let xConstructor be ? Get(x, "constructor").
    b. If SameValue(xConstructor, C) is true, return x.
  2. Let promiseCapability be ? NewPromiseCapability(C).
  3. Perform ? Call(promiseCapability.[[Resolve]], undefined, « x »).
  4. Return promiseCapability.[[Promise]].

Where [[Resolve]] for the intrinsic %Promise% constructor is:

  1. Let F be the active function object.
  2. Assert: F has a [[Promise]] internal slot whose value is an Object.
  3. Let promise be F.[[Promise]].
  4. Let alreadyResolved be F.[[AlreadyResolved]].
  5. If alreadyResolved.[[Value]] is true, return undefined.
  6. Set alreadyResolved.[[Value]] to true.
  7. If SameValue(resolution, promise) is true, then
    a. Let selfResolutionError be a newly created TypeError object.
    b. Perform RejectPromise(promise, selfResolutionError).
    c. Return undefined.
  8. If resolution is not an Object, then
    a. Perform FulfillPromise(promise, resolution).
    b. Return undefined.
  9. Let then be Completion(Get(resolution, "then")).
  10. If then is an abrupt completion, then
    a. Perform RejectPromise(promise, then.[[Value]]).
    b. Return undefined.
  11. Let thenAction be then.[[Value]].
  12. If IsCallable(thenAction) is false, then
    a. Perform FulfillPromise(promise, resolution).
    b. Return undefined.
  13. Let thenJobCallback be HostMakeJobCallback(thenAction).
  14. Let job be NewPromiseResolveThenableJob(promise, resolution, thenJobCallback).
  15. Perform HostEnqueuePromiseJob(job.[[Job]], job.[[Realm]]).
  16. Return undefined.

The most common reentrancy point in step 9 in the resolve steps above:

Get(resolution, "then")

The less common but still dangerous one is in step 1.a of Promise.resolve():

Get(x, "constructor")

Reentrancy cases

PromiseResolve

Any code using PromiseResolve(%Promise%, value) to transform a value into what they believe to be a safe promise value would encounter the following risks of reentrancy during PromiseResolve itself:

  • A promise object which has an own constructor getter property
  • A Promise subclass object which has a constructor getter property on its prototype
  • A promise object when the %Promise.prototype%'s constructor property has been modified into an accessor.

Please note that the value returned by PromiseResolve(%Promise%, value) may not in fact be safe to handle. See below.

Resolve steps

During the Resolve Function Steps, any resolution object which has a custom then behavior may cause reentrancy. Some notable cases:

  • An object with an own then accessor, including a promise object.
  • A proxy object with a get trap (cannot be a promise object)
  • An object with a then accessor on its prototype chain
    • an object created by the spec which inherits from %Object.prototype%, and %Object.prototype% had a .then accessor property added
    • a promise object which inherits from %Promise.prototype%, which has been modified with a then accessor property

The PromiseResolve operation may have returned a promise object with custom then behavior, either as an own property or through a modified %Promise.prototype%.

Any Call(promiseCapability.[[Resolve]] in the spec may in fact be susceptible to reentrancy if the result is one of these objects. In particular, PromiseResolve would have itself triggered these then custom behavior for any non promise object that didn't match the C promise constructor.

Proposed mitigation

Exact details TBD, but the general idea is to make PromiseResolve guaranteed to return a promise without causing reentrancy itself when called with the intrinsic %Promise% constructor. Then any spec code can use PerformPromiseThen on that returned promise and be guaranteed not to have caused reentrancy (or a throw), including Await.

User code can build a SafePromiseResolve that uses Promise.resolve() and verifies that the returned promise does not have own constructor and then properties (and inherits from %Promise.prototype% if not already checked by PromiseResolve itself), which it can all do safely since the returned promise is guaranteed to not be a proxy. The user code is then guaranteed that calling .then is safe (as long as Promise.prototype.then was not replaced, but that's not something it's in position to protect itself against unless the user code has first run on realm creation).

Unfortunately I don't think there is a way to make the "promise resolve function" not trigger any then hooks without either incurring a 1 tick delay for every non promise object used as resolution value, or allowing the creation of an async predicate that can detect whether an object is a proxy. Code interested in guarding itself against reentrancy would need to use PromiseResolve explicitly.

Related discussions

tc39/proposal-faster-promise-adoption#1 discusses some similar options for fast-pathing promise adoption which would prevent some reentrancy cases.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions