---
title: "CVE-2025-31205: How a Detached Stylesheet Exposed Cross-Origin Data"
description: "A reader-first analysis of CVE-2025-31205: how JavaScript kept a cross-origin stylesheet alive after detachment, why WebKit treated missing ownership as permission, and how the fix fails closed."
date: 2026-08-24
analysisType: "Variant Analysis"
cves: ["CVE-2025-31205"]
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/research/apple-webkit-orphaned-stylesheet-cve-2025-31205/
---

## CVE-2025-31205 in 60 seconds

A website may load a stylesheet from another origin for rendering, but the same-origin policy should
stop JavaScript from reading its CSS rules. CVE-2025-31205 broke that boundary after a lifecycle
change: script could keep a reference to a cross-origin `CSSStyleSheet`, remove its owning element
from the document, and then ask WebKit for the rules.

The attack path was:

```text
malicious website
  -> load a cross-origin stylesheet
  -> keep the CSSStyleSheet object and remove its owner element
  -> ownerDocument() becomes null
  -> WebKit's fallback returns "allowed"
  -> read or modify rules that should remain cross-origin
```

Apple describes the impact as cross-origin data exfiltration. The bug did not make the stylesheet
same-origin; WebKit lost the document context used for the comparison and interpreted “unknown” as
“allow.”

## What are the same-origin policy, CSSOM, and origin-clean state?

The same-origin policy prevents script from freely reading resources belonging to a different
scheme, host, or port. A page can often _use_ a cross-origin stylesheet to render, while the CSS
Object Model (CSSOM) still denies access to its rule list. Rendering permission and script-readable
data are different capabilities.

WebKit's `CSSStyleSheet::canAccessRules()` decides whether JavaScript may read or modify those rules.
An origin-clean value can preserve the authorization result with the stylesheet object. Without that
stored state, the old fallback tried to recover the owning `Document` and compare its origin with the
stylesheet URL.

## What happened?

JavaScript could retain a stylesheet obtained from a `<link>`, `<style>`, or imported sheet and then
remove the owning structure from the document. The stylesheet object survived, but
`ownerDocument()` returned null. In the vulnerable code, that missing document caused
`canAccessRules()` to return `true`, permitting reads and rule changes without an origin comparison.

WebKit reversed the unsafe default to `false`, propagated explicit origin-clean state through more
construction paths, and made `insertRule()` and `deleteRule()` use the same access decision. The new
layout tests cover attached and detached same-origin, cross-origin, CORS-enabled, redirected, and
imported stylesheets.

## Who was actually affected?

Apple fixed the issue in Safari 18.5 and corresponding May 2025 platform updates. The Apple advisory
lists Safari on macOS Ventura and Sonoma and states that a malicious website could exfiltrate data
cross-origin. WebKitGTK and WPE WebKit fixed the corresponding issue in 2.48.2. Exposure required a
vulnerable WebKit build and a page able to drive the affected stylesheet lifecycle; no browser
extension or local account was required for the web-content entry point.

## What I verified

- In the vulnerable branch, a missing `ownerDocument()` ended the check with `return true`; the
  patch changes that exact fallback to `return false`.
- The change adds an explicit origin-clean value so the access decision can survive after the
  stylesheet loses its document owner.
- `insertRule()` and `deleteRule()` now consult the same access decision used for reading rules.
- I inspected the added layout tests. They cover same-origin and cross-origin sheets while attached
  and detached, plus imported sheets; denied operations are expected to raise `SecurityError`.
- This is a static reproduction of the security decision and its fix. I inspected the patch and
  regression cases; I did not claim discovery or run an exfiltration exploit.

## Deep dive: the security decision had two paths

WebKit did not always calculate stylesheet access from scratch. When a `CSSStyleSheet` was created
with an explicit origin-clean value, `canAccessRules()` could use that recorded result. Otherwise,
the method used a fallback:

1. read the stylesheet's base URL;
2. find the document that owned the stylesheet;
3. compare the document's security origin with the stylesheet URL;
4. decide whether script could read or modify the rules.

The dangerous branch sat between steps two and three. If there was no owner document, the method
returned true. That answer converted “I no longer have enough context to compare origins” into
“access is allowed.”

<figure class="diagram">
<svg viewBox="0 0 760 310" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The vulnerable stylesheet access decision. An attached stylesheet is checked against its document origin. A detached stylesheet has no owner document and the old branch incorrectly grants access.">
<defs><marker id="css-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow" /></marker></defs>
<text x="0" y="24" class="dg-crit">VULNERABLE FALLBACK</text>
<rect x="0" y="50" width="180" height="70" rx="8" class="dg-box"/><text x="16" y="78" class="dg-label">CSSStyleSheet</text><text x="16" y="100" class="dg-muted">origin flag absent</text>
<line x1="182" y1="85" x2="230" y2="85" class="dg-line" marker-end="url(#css-a)"/>
<rect x="234" y="50" width="188" height="70" rx="8" class="dg-box"/><text x="250" y="78" class="dg-label">ownerDocument()</text><text x="250" y="100" class="dg-muted">which page owns it?</text>
<line x1="328" y1="122" x2="328" y2="174" class="dg-line dg-crit-line" marker-end="url(#css-a)"/>
<rect x="234" y="178" width="188" height="72" rx="8" class="dg-box-crit"/><text x="250" y="206" class="dg-label">No owner</text><text x="250" y="228" class="dg-crit">return true</text>
<line x1="424" y1="214" x2="490" y2="214" class="dg-line dg-crit-line" marker-end="url(#css-a)"/>
<rect x="494" y="178" width="210" height="72" rx="8" class="dg-box-crit"/><text x="510" y="206" class="dg-label">Cross-origin rules</text><text x="510" y="228" class="dg-crit">read / insert / delete</text>
<line x1="424" y1="85" x2="490" y2="85" class="dg-line" marker-end="url(#css-a)"/>
<rect x="494" y="50" width="210" height="70" rx="8" class="dg-box-accent"/><text x="510" y="78" class="dg-label">Origin comparison</text><text x="510" y="100" class="dg-accent">normal attached path</text>
<text x="0" y="292" class="dg-muted">The object survived. The context required to authorize access did not.</text>
</svg>
<figcaption>The vulnerable branch did not confuse two origins. It skipped the comparison when ownership context was missing.</figcaption>
</figure>

## Detaching the element changed context, not the stylesheet's origin

The public commit describes two routes to the ownerless state. Script could keep a reference to a
stylesheet obtained through a `<link>` or `<style>` element and then remove that element from the
document. A similar lifetime transition was possible for a stylesheet reached through an
`@import` rule when the containing stylesheet was removed.

The security property should survive that lifecycle change. Detaching a DOM node may change
rendering and ownership relationships, but it must not make previously cross-origin rules readable.
The stylesheet did not become same-origin. Only the convenient object used to perform the origin
comparison became unavailable.

This makes the issue a useful variant-hunting model. In browser engines, security decisions are
often distributed across an object graph. A node, frame, document, loader, or execution context may
carry the authority information. If the protected object outlives that context, fallback behavior
becomes part of the security boundary.

## The fix changes missing context to denial

The most important line in the patch reverses one default: when the fallback cannot obtain an
owner document, access is denied. WebKit also strengthens the surrounding construction paths so
more stylesheets carry an explicit same-origin flag, and it applies `canAccessRules()` consistently
to rule insertion and deletion as well as reading.

<figure class="diagram">
<svg viewBox="0 0 760 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Before and after comparison. The old ownerless branch allowed access. The fixed branch denies access while explicit same-origin state preserves valid detached stylesheet behavior.">
<text x="0" y="24" class="dg-crit">BEFORE</text>
<rect x="0" y="44" width="190" height="66" rx="8" class="dg-box"/><text x="16" y="70" class="dg-label">Detached sheet</text><text x="16" y="92" class="dg-muted">owner missing</text>
<rect x="218" y="44" width="190" height="66" rx="8" class="dg-box-crit"/><text x="234" y="70" class="dg-label">Fallback</text><text x="234" y="92" class="dg-crit">missing means allow</text>
<rect x="436" y="44" width="210" height="66" rx="8" class="dg-box-crit"/><text x="452" y="70" class="dg-label">Rules exposed</text><text x="452" y="92" class="dg-muted">cross-origin data</text>
<line x1="0" y1="140" x2="760" y2="140" class="dg-line dg-dash"/>
<text x="0" y="174" class="dg-accent">AFTER</text>
<rect x="0" y="194" width="190" height="66" rx="8" class="dg-box"/><text x="16" y="220" class="dg-label">Detached sheet</text><text x="16" y="242" class="dg-muted">origin state retained</text>
<rect x="218" y="194" width="190" height="66" rx="8" class="dg-box-accent"/><text x="234" y="220" class="dg-label">Explicit state</text><text x="234" y="242" class="dg-accent">or deny on unknown</text>
<rect x="436" y="194" width="210" height="66" rx="8" class="dg-box-accent"/><text x="452" y="220" class="dg-label">SecurityError</text><text x="452" y="242" class="dg-muted">boundary preserved</text>
</svg>
<figcaption>The fix uses two complementary controls: preserve origin state where possible and fail closed when the state cannot be recovered.</figcaption>
</figure>

This combination matters. Changing only null to false would protect cross-origin sheets but could
break legitimate detached same-origin stylesheets created without an explicit flag. Recording the
origin result at construction reduces dependence on a later owner lookup; denial remains the safe
fallback when that record is absent.

## Variant analysis: search for lost authority context

The reusable pattern is broader than CSS:

> A protected object remains reachable after the object that supplied its security context has
> been detached, destroyed, navigated, or replaced.

A focused WebKit or browser-engine review can search for access-control helpers that retrieve an
owner or context through nullable relationships. High-value candidates include code shaped like:

```text
context = object.ownerDocument() | frame() | scriptExecutionContext() | page()
if context is missing:
    allow, skip check, or return a permissive default
```

Not every such branch is a vulnerability. Some objects are intentionally safe when detached.
Candidates become meaningful only when all three conditions hold:

- the surviving object still exposes data or state-changing operations;
- the missing context previously supplied origin, permission, or principal information;
- an attacker can deliberately trigger the lifecycle transition while retaining the object.

<figure class="diagram">
<svg viewBox="0 0 760 304" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Variant hunting workflow from protected object through lifecycle transition and missing security context to a positive and negative control.">
<defs><marker id="hunt-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow"/></marker></defs>
<text x="0" y="24" class="dg-accent">VARIANT-HUNTING WORKFLOW</text>
<rect x="0" y="50" width="158" height="74" rx="8" class="dg-box"/><text x="16" y="78" class="dg-label">Protected object</text><text x="16" y="100" class="dg-muted">data or mutation</text>
<line x1="160" y1="87" x2="198" y2="87" class="dg-line" marker-end="url(#hunt-a)"/>
<rect x="202" y="50" width="158" height="74" rx="8" class="dg-box"/><text x="218" y="78" class="dg-label">Lifecycle shift</text><text x="218" y="100" class="dg-muted">detach / navigate</text>
<line x1="362" y1="87" x2="400" y2="87" class="dg-line" marker-end="url(#hunt-a)"/>
<rect x="404" y="50" width="158" height="74" rx="8" class="dg-box-crit"/><text x="420" y="78" class="dg-label">Context missing</text><text x="420" y="100" class="dg-crit">what is default?</text>
<line x1="564" y1="87" x2="602" y2="87" class="dg-line" marker-end="url(#hunt-a)"/>
<rect x="606" y="50" width="154" height="74" rx="8" class="dg-box-accent"/><text x="622" y="78" class="dg-label">Compare</text><text x="622" y="100" class="dg-muted">before / after</text>
<rect x="202" y="188" width="158" height="70" rx="8" class="dg-box-accent"/><text x="218" y="216" class="dg-label">Positive control</text><text x="218" y="238" class="dg-muted">same-origin works</text>
<rect x="404" y="188" width="158" height="70" rx="8" class="dg-box-accent"/><text x="420" y="216" class="dg-label">Negative control</text><text x="420" y="238" class="dg-muted">cross-origin denied</text>
<line x1="360" y1="223" x2="402" y2="223" class="dg-line dg-dash"/>
<text x="202" y="286" class="dg-muted">A crash is not required: the assertion is the authorization result.</text>
</svg>
<figcaption>Variant analysis starts from the failed invariant, then uses matched positive and negative controls to distinguish a security fix from ordinary lifecycle behavior.</figcaption>
</figure>

## Safe validation does not need real cross-origin data

WebKit's added layout tests provide the right model. A controlled harness can serve two synthetic
origins, each containing non-sensitive test CSS. The test records access while the stylesheet is
attached, removes the owning element, and repeats the same operation through the retained object.

The minimum matrix should include:

- same-origin stylesheet, attached and detached: access remains available;
- cross-origin stylesheet without CORS, attached and detached: access throws `SecurityError`;
- cross-origin stylesheet with successful CORS: access follows the explicit origin-clean state;
- imported stylesheet after its parent is detached: the same rules remain enforced;
- `cssRules`, `insertRule()`, and `deleteRule()`: all use the same access decision.

This proves the boundary without reading a third party's content. It also avoids an incomplete
test that checks only `cssRules` while mutation methods follow a different path.

## Evidence matrix

| Signal                                                  | What it proves                                                   | What it does not prove                                  | My check                                                                  |
| ------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------- |
| Apple maps CVE-2025-31205 to Bugzilla 290992            | The vendor connects the CVE to WebKit's cross-origin data impact | The complete discovery history or weaponized exploit    | I matched the Safari 18.5 advisory with the public commit                 |
| `ownerDocument() == nullptr` changed from allow to deny | Missing ownership context was a permissive access branch         | That every ownerless stylesheet was attacker controlled | I inspected `CSSStyleSheet::canAccessRules()` before and after the change |
| Construction paths now supply origin-clean state        | The fix preserves valid decisions beyond document lifetime       | That all browser objects use the same lifetime model    | I reviewed the call-site changes and added layout tests                   |
| Read, insert, and delete operations share the gate      | The patch closes both disclosure and mutation routes             | The exact data selected in any private exploit          | I traced all three operations to the shared access decision               |
| WebKitGTK/WPE advisory fixes versions before 2.48.2     | The issue affected ports beyond Safari                           | Identical exploitability on every embedding application | I compared the port advisory and fixed-version record                     |

## My conclusion

Users should update to Safari 18.5 or the corresponding fixed Apple platform release. WebKitGTK
and WPE WebKit consumers should use 2.48.2 or a distributor build that explicitly backports the
fix. Version strings alone can mislead when distributions backport patches, so the package
security record is the deciding source.

My conclusion is that the bug was not simply “detached CSS.” The unsafe part was the fallback that
treated missing ownership information as permission. When an object outlives the context that
authorized it, the implementation must carry the earlier decision forward or deny the operation.
A null context is a reason to stop, not a successful origin comparison.

## Public sources

- [Apple: Safari 18.5 security content](https://support.apple.com/en-us/122719)
- [WebKit commit: Tighten up cross-site access to CSSStyleSheet](https://github.com/WebKit/WebKit/commit/647e80ac22)
- [Canonical WebKit commit 295342@main](https://commits.webkit.org/295342@main)
- [WebKitGTK and WPE WebKit advisory WSA-2025-0004](https://wpewebkit.org/security/WSA-2025-0004.html)