Closed Bug 2016237 Opened 1 month ago Closed 1 month ago

CSSStyleSheet.replace() reentrancy via thenable getter causes state corruption

Categories

(Core :: DOM: CSS Object Model, defect, P3)

defect

Tracking

()

RESOLVED FIXED
150 Branch
Tracking Status
firefox150 --- fixed

People

(Reporter: ehdgks0627, Assigned: emilio)

References

Details

Attachments

(2 files)

User Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36

Steps to reproduce:

StyleSheet::MaybeResolveReplacePromise() calls mReplacePromise->MaybeResolve(this) followed by mReplacePromise = nullptr. Because SpiderMonkey's promise resolution synchronously accesses Get(resolution, "then") per the ECMAScript specification (Promise Resolve Functions, step 9), a getter defined on CSSStyleSheet.prototype.then can re-enter replace() before the nullptr assignment executes. This permanently corrupts the stylesheet's internal state.

  1. Create a constructable CSSStyleSheet and adopt it into a document.
  2. Define a getter on CSSStyleSheet.prototype.then that calls sheet.replace() once, then returns undefined.
  3. Call sheet.replace() and await the returned Promise.

Minimal reproducer:

<!DOCTYPE html>
<script>
"use strict";
(async () => {
  const sheet = new CSSStyleSheet();
  document.adoptedStyleSheets = [sheet];

  let p2 = null;
  let once = false;

  Object.defineProperty(CSSStyleSheet.prototype, "then", {
    configurable: true,
    get() {
      if (!once) {
        once = true;
        p2 = sheet.replace("b { color: blue; }");
      }
      return undefined;
    },
  });

  await sheet.replace("a { color: red; }");
  delete CSSStyleSheet.prototype.then;

  // Check if p2 settles
  const result = await Promise.race([
    p2.then(() => "resolved", e => "rejected: " + e),
    new Promise(r => setTimeout(() => r("TIMEOUT"), 3000)),
  ]);
  console.log("p2:", result);

  // Check if sheet is still modifiable
  try { sheet.replaceSync("c {}"); }
  catch (e) { console.log("locked:", e.message); }
})();
</script>


Actual results:

- The second `replace()` Promise **never settles** (orphaned). `console.log` prints `p2: TIMEOUT`.
- The sheet is **permanently locked**: `replace()`, `replaceSync()`, `insertRule()`, `deleteRule()` all throw `NotAllowedError` ("Can only be called on modifiable style sheets").
- The second parse completes and silently replaces the sheet's CSS rules, but with no Promise notification.
- In debug builds, `MOZ_ASSERT(!mReplacePromise)` fires at `StyleSheet.cpp:688`.



Expected results:

- Both `replace()` calls should return independently settling Promises.
- The sheet should remain modifiable after both Promises settle.
- Or, the reentrant `replace()` should be rejected, preserving state consistency.

## Root Cause

`layout/style/StyleSheet.cpp`, lines 612-621:

```cpp
void StyleSheet::MaybeResolveReplacePromise() {
  MOZ_ASSERT(!!mReplacePromise == ModificationDisallowed());
  if (!mReplacePromise) {
    return;
  }

  SetModificationDisallowed(false);       // line 618
  mReplacePromise->MaybeResolve(this);    // line 619 - JS runs here (thenable check)
  mReplacePromise = nullptr;              // line 620 - destroys reentrant promise
}

MaybeResolve(this) wraps the C++ StyleSheet as a JS object and passes it through the standard ECMAScript promise resolution, which synchronously calls GetProperty(cx, obj, "then") in ResolvePromiseInternal() (js/src/builtin/Promise.cpp:1442). A user-defined getter on CSSStyleSheet.prototype.then executes in-stack.

The call chain:

MaybeResolve(this)
  -> Promise::MaybeSomething()             // Promise.h:467
    -> ToJSValue(cx, stylesheetPtr, &val)
    -> JS::ResolvePromise(cx, p, val)
      -> ResolvePromiseInternal()          // Promise.cpp:1484
        -> GetThenValue()                  // Promise.cpp:1521
          -> GetProperty(cx, obj, "then")  // Promise.cpp:1442 -- SYNC getter

During the getter, ModificationDisallowed() is already false (cleared at line 618), so the reentrant replace() passes the check at line 662 and:

  1. Sets ModificationDisallowed(true) (line 670)
  2. Overwrites mReplacePromise with a new Promise (line 689)

After the getter returns, line 620 executes mReplacePromise = nullptr, destroying the new Promise. The invariant !!mReplacePromise == ModificationDisallowed() is permanently broken:

  • mReplacePromise == nullptr
  • ModificationDisallowed() == true
Component: Untriaged → CSS Parsing and Computation
Product: Firefox → Core

This sync then access on promise resolution has bit us in the past, see bug 1923344 for example which had the same root cause.

:arai, do you know if this is per spec? Other browsers don't seem to do this.

Severity: -- → S3
Status: UNCONFIRMED → NEW
Component: CSS Parsing and Computation → DOM: CSS Object Model
Ever confirmed: true
Flags: needinfo?(arai.unmht)
Priority: -- → P3
See Also: → CVE-2024-9680

This fixes the immediate issue, but it's quite the footgun...

Assignee: nobody → emilio
Status: NEW → ASSIGNED

(In reply to Emilio Cobos Álvarez [:emilio] from comment #2)

This sync then access on promise resolution has bit us in the past, see bug 1923344 for example which had the same root cause.

:arai, do you know if this is per spec? Other browsers don't seem to do this.

In the ECMAScript side, it's per spec.
As long as the "resolve" used there is WebIDL's resolve, it calls the resolving function:

https://drafts.csswg.org/cssom/#dom-cssstylesheet-replace

The replace(text) method must run the following steps:

  ...
  4. *In parallel*, do these steps:
    ...
    5. Resolve _promise_ with _sheet_.

and the step 2.e there accessed the "then" property of the resolution value, which can trigger a getter function.

https://tc39.es/ecma262/#sec-createresolvingfunctions

CreateResolvingFunctions ( promise )

  ...
  2. Let resolveSteps be a new Abstract Closure with parameters
     (resolution) that captures promise and alreadyResolved and performs the
     following steps when called:
    ...
    e. Let then be Completion(Get(resolution, "then")).

There's a proposal to address these similar issues in https://github.com/tc39/proposal-thenable-curtailment

Then, in the CSS side, I think the problem is that the promise is held by a shared slot StyleSheet::mReplacePromise and easily accessible from other replace invocation.
In terms of the spec, the promise created inside the StyleSheet.prototype.replace is not stored anywhere, but captured by the parallel steps.
If we can store the promise in a storage that's allocated per each replace invocation, this could be solved without worrying about the "then" handling,
but of course that means we need to carry the information over the following call path,
and using the shared slot with a kind of lock mechanism may be simpler:

https://searchfox.org/firefox-main/query/default?q=calls-between-source%3A%27mozilla%3A%3Adom%3A%3ACSSStyleSheet_Binding%3A%3Areplace%27+calls-between-target%3A%27mozilla%3A%3AStyleSheet%3A%3AMaybeResolveReplacePromise%27+depth%3A9+hier%3Aflatbadges

One possible option would be to merge the SetModificationDisallowed with mReplacePromise field ownership.
Like the following:

void SetModificationDisallowedWith(RefPtr<ReplacePromise>& promise) {
  mState |= State::ModificationDisallowed;  // with some more complex logic
  mReplacePromiseDoNotUseDirectly = promise;
}

ReplacePromise* UnsetModificationDisallowed() {
  mState &= ~State::ModificationDisallowed; // with some more complex logic
  return mReplacePromiseDoNotUseDirectly.forget();
}

with the following consumers:

void StyleSheet::MaybeResolveReplacePromise() {
  ...

  RefPtr promise = UnsetModificationDisallowed();
  promise->MaybeResolve(this);
}

void StyleSheet::MaybeRejectReplacePromise() {
  ...

  RefPtr promise = UnsetModificationDisallowed();
  promise->MaybeRejectWithNetworkError(
      "@import style sheet load failed");
}
Flags: needinfo?(arai.unmht)

Yeah the main issue is resolving the promise potentially triggering script is something a bit unexpected.

Attachment #9547043 - Attachment description: Bug 2016237 - Take promise before resolving. r=#style → Bug 2016237 - Take CSSStyleSheet's replace() promise before resolving. r=#style
Pushed by ealvarez@mozilla.com: https://github.com/mozilla-firefox/firefox/commit/06979f0bbcef https://hg.mozilla.org/integration/autoland/rev/bb1692cb62d5 Take CSSStyleSheet's replace() promise before resolving. r=firefox-style-system-reviewers,dshin
Created web-platform-tests PR https://github.com/web-platform-tests/wpt/pull/57993 for changes under testing/web-platform/tests
Status: ASSIGNED → RESOLVED
Closed: 1 month ago
Resolution: --- → FIXED
Target Milestone: --- → 150 Branch
Upstream PR merged by moz-wptsync-bot
Upstream PR merged by moz-wptsync-bot
QA Whiteboard: [qa-triage-done-c151/b150]
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Creator:
Created:
Updated:
Size: