Medium CVSS 6.5 webkit Cross Origin 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionA malicious website may exfiltrate data cross-origin
ComponentWebCore
Sub-componentWebCore CSS
Bug ClassCross Origin
Tracker290992
Fix commit647e80ac22b3 (WebKit/WebKit) +243/-19
CWECWE-352 (CSRF)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedIvan Fratric of Google Project Zero
Disclosed2025-05-12

Background

`CSSStyleSheet`
The CSSOM wrapper object exposing a stylesheet’s cssRules, insertRule, and deleteRule to JavaScript.
`canAccessRules()`
The origin gate on CSSStyleSheet that decides whether script may read or mutate a sheet’s rules based on same-origin/CORS checks.
Origin-clean flag
The m_isOriginClean (isOriginClean) tri-state (std::optional<bool>) that records whether a sheet’s bytes were fetched same-origin or via a successful CORS check.
`isCORSSameOrigin()`
The CachedCSSStyleSheet accessor reporting whether the loaded resource satisfies CORS/same-origin, the authoritative source for the origin-clean flag.

Root Cause Analysis

When JavaScript reaches a CSSStyleSheet whose owning node or import rule has been detached from the document, canAccessRules() computed access from ownerDocument(), and when that document was nullptr it returned true, treating an orphaned sheet as freely accessible. Because cross-origin sheets loaded without CORS never had their origin-clean state persisted onto the wrapper (HTMLLinkElement::initializeStyleSheet only set originClean in FetchOptions::Mode::Cors, and CSSImportRule/ProcessingInstruction set nothing at all), a page could load a cross-site stylesheet, drop the <link>/<style> from the DOM, and then read cssRules on the now-parentless sheet, exfiltrating cross-origin CSS content.

The fix carries the true CORS-same-origin result from CachedCSSStyleSheet::isCORSSameOrigin() into the CSSStyleSheet at construction for every load path (link, @import, and processing instruction) and flips the detached-document case in canAccessRules() to return false, so an orphaned sheet fails closed rather than open. It additionally hardens the mutators insertRule and deleteRule with the same canAccessRules() gate that already protected reads, closing the write side of the cross-origin surface. The invariant restored is that a sheet’s origin-clean status is fixed by its fetch and must survive detachment, never inferred from a mutable document link.

Key insight
The core mistake was deriving cross-origin access from the live ownerDocument() at call time while failing to persist the fetch-time origin-clean bit onto the wrapper, so detaching the sheet from the DOM erased the security boundary. The fix records isCORSSameOrigin() into m_isOriginClean at construction and makes the missing-document branch of canAccessRules() deny access.

Attack Path

  1. Embed a cross-site stylesheet The attacker page loads a victim origin’s CSS via <link rel="stylesheet"> (no crossorigin), @import, or an XML processing instruction, whose response bytes are not origin-clean.
  2. Detach the owner from the DOM Script removes the <link>/<style>/PI node so the resulting CSSStyleSheet wrapper has no ownerDocument().
  3. Read the orphaned sheet Script calls stylesheet.cssRules (or previously-ungated insertRule/deleteRule), and the old canAccessRules() returns true for the null-document case.
  4. Exfiltrate cross-origin CSS The attacker serializes the victim’s rule text and ships it off-origin, leaking data the CSS content encodes.

Impact Assessment

An attacker gains cross-origin read access to another site’s stylesheet contents (and, via the ungated mutators, the ability to alter rules), enabling exfiltration of data that the CSS bytes reveal. This executes in the web content process under the attacker page’s script context, in the same renderer that hosts the cross-origin sheet. Preconditions are only that the attacker can cause a cross-site stylesheet to load (a public URL and a <link>, @import, or PI) and then detach its owner node before reading it.

Changed Functions

FunctionChangeNotes
if
Source/WebCore/html/HTMLLinkElement.cpp
modified
for
LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document.html
modified
if
LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document.html
modified

Files Changed

  • LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document-expected.txt
  • LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document.html
  • LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document-expected.txt
  • LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document.html
  • LayoutTests/http/tests/security/cannot-read-cssrules-redirect-expected.txt
  • LayoutTests/imported/w3c/web-platform-tests/service-workers/service-worker/fetch-request-css-cross-origin.https-expected.txt
  • Source/WebCore/css/CSSImportRule.cpp
  • Source/WebCore/css/CSSStyleSheet.cpp
  • Source/WebCore/css/CSSStyleSheet.h
  • Source/WebCore/dom/ProcessingInstruction.cpp
  • Source/WebCore/html/HTMLLinkElement.cpp

Audit Directions

  • Fetch-time security state persisted on wrappers
    Verify that any origin/CORS bit determining later access (like m_isOriginClean) is captured at load time and stored on the object, never recomputed from mutable DOM links that can be severed.
  • Fail-closed on missing context
    Audit access-control predicates for branches that return true when a document, frame, or origin is nullptr; detachment-based null contexts should deny, not grant.
  • Symmetric gating of readers and mutators
    Ensure every read gate (canAccessRules() on cssRules) has a matching gate on the mutation APIs (insertRule, deleteRule) and similar setters, since attackers exploit whichever entry point is left ungated.
diff --git a/Source/WebCore/css/CSSImportRule.cpp b/Source/WebCore/css/CSSImportRule.cpp
index 9356bbc7e758..aae10a569d7f 100644
--- a/Source/WebCore/css/CSSImportRule.cpp
+++ b/Source/WebCore/css/CSSImportRule.cpp
@@ -26,6 +26,7 @@
 #include "CSSMarkup.h"
 #include "CSSSerializationContext.h"
 #include "CSSStyleSheet.h"
+#include "CachedCSSStyleSheet.h"
 #include "MediaList.h"
 #include "MediaQueryParser.h"
 #include "StyleRuleImport.h"
@@ -121,8 +122,13 @@ CSSStyleSheet* CSSImportRule::styleSheet() const
 { 
     if (!m_importRule.get().styleSheet())
         return nullptr;
+
+    std::optional<bool> isOriginClean;
+    if (const auto* cachedSheet = m_importRule->cachedCSSStyleSheet())
+        isOriginClean = cachedSheet->isCORSSameOrigin();
+
     if (!m_styleSheetCSSOMWrapper)
-        m_styleSheetCSSOMWrapper = CSSStyleSheet::create(*m_importRule.get().styleSheet(), const_cast<CSSImportRule*>(this));
+        m_styleSheetCSSOMWrapper = CSSStyleSheet::create(*m_importRule.get().styleSheet(), const_cast<CSSImportRule*>(this), isOriginClean);
     return m_styleSheetCSSOMWrapper.get(); 
 }
 
diff --git a/Source/WebCore/css/CSSStyleSheet.cpp b/Source/WebCore/css/CSSStyleSheet.cpp
index d2df9aa422ff..8dbb03d13225 100644
--- a/Source/WebCore/css/CSSStyleSheet.cpp
+++ b/Source/WebCore/css/CSSStyleSheet.cpp
@@ -90,9 +90,9 @@ static bool isAcceptableCSSStyleSheetParent(Node* parentNode)
 }
 #endif // ASSERT_ENABLED
 
-Ref<CSSStyleSheet> CSSStyleSheet::create(Ref<StyleSheetContents>&& sheet, CSSImportRule* ownerRule)
+Ref<CSSStyleSheet> CSSStyleSheet::create(Ref<StyleSheetContents>&& sheet, CSSImportRule* ownerRule, std::optional<bool> isOriginClean)
 {
-    return adoptRef(*new CSSStyleSheet(WTFMove(sheet), ownerRule));
+    return adoptRef(*new CSSStyleSheet(WTFMove(sheet), ownerRule, isOriginClean));
 }
 
 Ref<CSSStyleSheet> CSSStyleSheet::create(Ref<StyleSheetContents>&& sheet, Node& ownerNode, const std::optional<bool>& isCleanOrigin)
@@ -121,8 +121,9 @@ ExceptionOr<Ref<CSSStyleSheet>> CSSStyleSheet::create(Document& document, Init&&
     return adoptRef(*new CSSStyleSheet(StyleSheetContents::create(parserContext), document, WTFMove(init)));
 }
 
-CSSStyleSheet::CSSStyleSheet(Ref<StyleSheetContents>&& contents, CSSImportRule* ownerRule)
+CSSStyleSheet::CSSStyleSheet(Ref<StyleSheetContents>&& contents, CSSImportRule* ownerRule, std::optional<bool> isOriginClean)
     : m_contents(WTFMove(contents))
+    , m_isOriginClean(isOriginClean)
     , m_ownerRule(ownerRule)
 {
     if (auto* parent = parentStyleSheet())
@@ -337,9 +338,11 @@ bool CSSStyleSheet::canAccessRules() const
     URL baseURL = m_contents->baseURL();
     if (baseURL.isEmpty())
         return true;
+
     Document* document = ownerDocument();
     if (!document)
-        return true;
+        return false;
+
     return document->protectedSecurityOrigin()->canRequest(baseURL, OriginAccessPatternsForWebProcess::singleton());
 }
 
@@ -349,6 +352,9 @@ ExceptionOr<unsigned> CSSStyleSheet::insertRule(const String& ruleString, unsign
 
     ASSERT(m_childRuleCSSOMWrappers.isEmpty() || m_childRuleCSSOMWrappers.size() == m_contents->ruleCount());
 
+    if (!canAccessRules())
+        return Exception { ExceptionCode::SecurityError, "Not allowed to insert rule into cross-origin stylesheet"_s };
+
     if (index > length())
         return Exception { ExceptionCode::IndexSizeError };
     RefPtr rule = CSSParser::parseRule(ruleString, m_contents.get().parserContext(), m_contents.ptr(), CSSParser::AllowedRules::ImportRules);
@@ -376,6 +382,9 @@ ExceptionOr<void> CSSStyleSheet::deleteRule(unsigned index)
 
     ASSERT(m_childRuleCSSOMWrappers.isEmpty() || m_childRuleCSSOMWrappers.size() == m_contents->ruleCount());
 
+    if (!canAccessRules())
+        return Exception { ExceptionCode::SecurityError, "Not allowed to delete rule from cross-origin stylesheet"_s };
+
     if (index >= length())
         return Exception { ExceptionCode::IndexSizeError };
     RuleMutationScope mutationScope(this);
diff --git a/Source/WebCore/css/CSSStyleSheet.h b/Source/WebCore/css/CSSStyleSheet.h
index 5a3438c05f57..4db0ac776ff9 100644
--- a/Source/WebCore/css/CSSStyleSheet.h
+++ b/Source/WebCore/css/CSSStyleSheet.h
@@ -64,7 +64,7 @@ public:
         Variant<RefPtr<MediaList>, String> media { emptyString() };
         bool disabled { false };
     };
-    static Ref<CSSStyleSheet> create(Ref<StyleSheetContents>&&, CSSImportRule* ownerRule = 0);
+    static Ref<CSSStyleSheet> create(Ref<StyleSheetContents>&&, CSSImportRule* ownerRule = nullptr, std::optional<bool>isOriginClean = std::nullopt);
     static Ref<CSSStyleSheet> create(Ref<StyleSheetContents>&&, Node& ownerNode, const std::optional<bool>& isOriginClean = std::nullopt);
     static Ref<CSSStyleSheet> createInline(Ref<StyleSheetContents>&&, Element& owner, const TextPosition& startPosition);
     static ExceptionOr<Ref<CSSStyleSheet>> create(Document&, Init&&);
@@ -164,7 +164,7 @@ public:
     bool isDetached() const;
 
 private:
-    CSSStyleSheet(Ref<StyleSheetContents>&&, CSSImportRule* ownerRule);
+    CSSStyleSheet(Ref<StyleSheetContents>&&, CSSImportRule* ownerRule, std::optional<bool> isOriginClean);
     CSSStyleSheet(Ref<StyleSheetContents>&&, Node* ownerNode, const TextPosition& startPosition, bool isInlineStylesheet);
     CSSStyleSheet(Ref<StyleSheetContents>&&, Node& ownerNode, const TextPosition& startPosition, bool isInlineStylesheet, const std::optional<bool>&);
     CSSStyleSheet(Ref<StyleSheetContents>&&, Document&, Init&&);
diff --git a/Source/WebCore/dom/ProcessingInstruction.cpp b/Source/WebCore/dom/ProcessingInstruction.cpp
index 8c479fa86bef..14ba0c0596f2 100644
--- a/Source/WebCore/dom/ProcessingInstruction.cpp
+++ b/Source/WebCore/dom/ProcessingInstruction.cpp
@@ -190,11 +190,13 @@ void ProcessingInstruction::setCSSStyleSheet(const String& href, const URL& base
         return;
     }
 
+    ASSERT(sheet);
+
     Ref document = this->document();
     ASSERT(m_isCSS);
     CSSParserContext parserContext(document, baseURL, charset);
 
-    Ref cssSheet = CSSStyleSheet::create(StyleSheetContents::create(href, parserContext), *this);
+    Ref cssSheet = CSSStyleSheet::create(StyleSheetContents::create(href, parserContext), *this, sheet->isCORSSameOrigin());
     cssSheet->setDisabled(m_alternate);
     cssSheet->setTitle(m_title);
     cssSheet->setMediaQueries(MQ::MediaQueryParser::parse(m_media, MediaQueryParserContext(document)));
diff --git a/Source/WebCore/html/HTMLLinkElement.cpp b/Source/WebCore/html/HTMLLinkElement.cpp
index 652f1f7cfb34..d05470b9be45 100644
--- a/Source/WebCore/html/HTMLLinkElement.cpp
+++ b/Source/WebCore/html/HTMLLinkElement.cpp
@@ -579,17 +579,12 @@ void HTMLLinkElement::finishParsingChildren()
 
 void HTMLLinkElement::initializeStyleSheet(Ref<StyleSheetContents>&& styleSheet, const CachedCSSStyleSheet& cachedStyleSheet, MediaQueryParserContext context)
 {
-    // FIXME: originClean should be turned to false except if fetch mode is CORS.
-    std::optional<bool> originClean;
-    if (cachedStyleSheet.options().mode == FetchOptions::Mode::Cors)
-        originClean = cachedStyleSheet.isCORSSameOrigin();
-
     if (m_sheet) {
         ASSERT(m_sheet->ownerNode() == this);
         m_sheet->clearOwnerNode();
     }
 
-    m_sheet = CSSStyleSheet::create(WTFMove(styleSheet), *this, originClean);
+    m_sheet = CSSStyleSheet::create(WTFMove(styleSheet), *this, cachedStyleSheet.isCORSSameOrigin());
     m_sheet->setMediaQueries(MQ::MediaQueryParser::parse(m_media, context));
     if (!isInShadowTree())
         m_sheet->setTitle(title());
diff --git a/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document-expected.txt b/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document-expected.txt
new file mode 100644
index 000000000000..cc77e09161e7
--- /dev/null
+++ b/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document-expected.txt
@@ -0,0 +1,33 @@
+Tests accessing CSSStyleSheet after removing from the document
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document.html b/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document.html
new file mode 100644
index 000000000000..4be151a8584c
--- /dev/null
+++ b/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+
+<head>
+
+<title>Accessing CSSStyleSheet after removing from document</title>
+<script src="/resources/js-test-pre.js"></script>
+
+<!--
+    Explicitly use <link> here instead of dynamically creating <link> elements
+    in JavaScript, so they're loaded before the JavaScript runs.
+-->
+
+<!-- Inline style -->
+<style id="local-style">#element { margin: 10px; }</style>
+
+<!-- Same origin -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="/security/resources/cssStyle.css"
+      data-accessible="true">
+
+<!-- Cross origin, no CORS -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="http://localhost:8000/security/resources/cssStyle.css"
+      data-accessible="false">
+
+<!-- Cross origin, CORS succeeds -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="http://localhost:8000/security/resources/xorigincss1-allow-star.py"
+      crossorigin=""
+      data-accessible="true">
+
+<!-- Same origin -> same origin -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css"
+      data-accessible="true">
+
+<!-- Same origin -> cross origin, no CORS -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="/resources/redirect.py?url=http://localhost:8000/security/resources/cssStyle.css"
+      data-accessible="false">
+
+<!-- Cross origin -> same origin, no CORS -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="http://localhost:8000/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css"
+      data-accessible="false">
+
+<!-- Cross origin -> same origin, CORS succeeds -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="http://localhost:8000/security/resources/redirect-allow-star.py?url=http://127.0.0.1:8000/security/resources/xorigincss1-allow-star.py"
+      crossorigin=""
+      data-accessible="true">
+
+<!-- Same origin -> cross origin -> same origin -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="http://127.0.0.1:8000/resources/redirect.py?url=http://localhost:8000/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css"
+      data-accessible="false">
+
+</head>
+
+<body>
+
+<script>
+    description("Tests accessing CSSStyleSheet after removing from the document");
+
+    const link_elements = Array.from(document.getElementsByClassName("test-stylesheet"));
+    for (const element of link_elements) {
+        const stylesheet = element.sheet;
+
+        window.document.head.removeChild(element);
+
+        if (element.dataset.accessible === "true") {
+            shouldNotThrow(() => stylesheet.cssRules);
+            shouldNotThrow(() => stylesheet.insertRule('#element2 { margin: 10px; }'));
+            shouldNotThrow(() => stylesheet.deleteRule(0));
+        } else {
+            shouldThrowErrorName(() => stylesheet.cssRules, "SecurityError");
+            shouldThrowErrorName(() => stylesheet.insertRule('body { margin: 10px; } '), "SecurityError");
+            shouldThrowErrorName(() => stylesheet.deleteRule(0), "SecurityError");
+        }
+    }
+</script>
+
+<script src="/resources/js-test-post.js"></script>
+
+</body>
diff --git a/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document-expected.txt b/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document-expected.txt
new file mode 100644
index 000000000000..321f2c3eab2a
--- /dev/null
+++ b/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document-expected.txt
@@ -0,0 +1,24 @@
+Tests accessing @import-ed CSSStyleSheet after
+
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document.html b/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document.html
new file mode 100644
index 000000000000..d396755fe6b0
--- /dev/null
+++ b/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+
+<head>
+
+<title>Accessing @import-ed CSSStyleSheet after removing from document</title>
+<script src="/resources/js-test-pre.js"></script>
+
+<style id="local-style">
+    /* Rule 0: same origin */
+    @import url('/security/resources/cssStyle.css');
+
+    /* Rule 1: cross origin */
+    @import url('http://localhost:8000/security/resources/cssStyle.css');
+
+    /* Rule 3: same origin -> same origin */
+    @import url('/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css');
+
+    /* Rule 4: same origin -> cross origin */
+    @import url('/resources/redirect.py?url=http://localhost:8000/security/resources/cssStyle.css');
+
+    /* Rule 5: cross origin -> same origin */
+    @import url('http://localhost:8000/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css');
+
+    /* Rule 6: same origin -> cross origin -> same origin */
+    @import url('http://127.0.0.1:8000/resources/redirect.py?url=http://localhost:8000/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css');
+</style>
+
+</head>
+
+<body>
+
+<script>
+    description("Tests accessing @import-ed CSSStyleSheet after <style> is removed from the document");
+
+    let local_style = document.getElementById("local-style");
+    let local_stylesheet = local_style.sheet;
+
+    // Remove the styles from the document
+    window.document.head.removeChild(local_style);
+
+    function check_accessible(stylesheet) {
+        shouldNotThrow(() => stylesheet.cssRules);
+        shouldNotThrow(() => stylesheet.insertRule('#element2 { margin: 10px; }'));
+        shouldNotThrow(() => stylesheet.deleteRule(0));
+    }
+
+    function check_inaccessible(stylesheet) {
+        shouldThrowErrorName(() => stylesheet.cssRules, "SecurityError");
+        shouldThrowErrorName(() => stylesheet.insertRule('body { margin: 10px; } '), "SecurityError");
+        shouldThrowErrorName(() => stylesheet.deleteRule(0), "SecurityError");
+    }
+
+    check_accessible(local_stylesheet.rules[0].styleSheet);
+    check_inaccessible(local_stylesheet.rules[1].styleSheet);
+    check_accessible(local_stylesheet.rules[2].styleSheet);
+    check_inaccessible(local_stylesheet.rules[3].styleSheet);
+    check_inaccessible(local_stylesheet.rules[4].styleSheet);
+    check_inaccessible(local_stylesheet.rules[5].styleSheet);
+</script>
+
+<script src="/resources/js-test-post.js"></script>
+
+</body>
+
diff --git a/LayoutTests/http/tests/security/cannot-read-cssrules-redirect-expected.txt b/LayoutTests/http/tests/security/cannot-read-cssrules-redirect-expected.txt
index 6cbdfa1e0175..4d77f44527cc 100644
--- a/LayoutTests/http/tests/security/cannot-read-cssrules-redirect-expected.txt
+++ b/LayoutTests/http/tests/security/cannot-read-cssrules-redirect-expected.txt
@@ -6,10 +6,8 @@ Test begins.
 exception thrown for cssRules: SecurityError: Not allowed to access cross-origin stylesheet
 exception thrown for rules: SecurityError: Not allowed to access cross-origin stylesheet
 == Cross-Origin to Same-Origin, no-cors mode ==
-cssRules: [object CSSRuleList]
-cssRules length: 1
-rules: [object CSSRuleList]
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document-expected.txt b/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document-expected.txt
new file mode 100644
index 000000000000..cc77e09161e7
--- /dev/null
+++ b/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document-expected.txt
@@ -0,0 +1,33 @@
+Tests accessing CSSStyleSheet after removing from the document
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document.html b/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document.html
new file mode 100644
index 000000000000..4be151a8584c
--- /dev/null
+++ b/LayoutTests/http/tests/security/access-cssstylesheet-after-removing-from-document.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+
+<head>
+
+<title>Accessing CSSStyleSheet after removing from document</title>
+<script src="/resources/js-test-pre.js"></script>
+
+<!--
+    Explicitly use <link> here instead of dynamically creating <link> elements
+    in JavaScript, so they're loaded before the JavaScript runs.
+-->
+
+<!-- Inline style -->
+<style id="local-style">#element { margin: 10px; }</style>
+
+<!-- Same origin -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="/security/resources/cssStyle.css"
+      data-accessible="true">
+
+<!-- Cross origin, no CORS -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="http://localhost:8000/security/resources/cssStyle.css"
+      data-accessible="false">
+
+<!-- Cross origin, CORS succeeds -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="http://localhost:8000/security/resources/xorigincss1-allow-star.py"
+      crossorigin=""
+      data-accessible="true">
+
+<!-- Same origin -> same origin -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css"
+      data-accessible="true">
+
+<!-- Same origin -> cross origin, no CORS -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="/resources/redirect.py?url=http://localhost:8000/security/resources/cssStyle.css"
+      data-accessible="false">
+
+<!-- Cross origin -> same origin, no CORS -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="http://localhost:8000/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css"
+      data-accessible="false">
+
+<!-- Cross origin -> same origin, CORS succeeds -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="http://localhost:8000/security/resources/redirect-allow-star.py?url=http://127.0.0.1:8000/security/resources/xorigincss1-allow-star.py"
+      crossorigin=""
+      data-accessible="true">
+
+<!-- Same origin -> cross origin -> same origin -->
+<link class="test-stylesheet"
+      rel="stylesheet"
+      href="http://127.0.0.1:8000/resources/redirect.py?url=http://localhost:8000/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css"
+      data-accessible="false">
+
+</head>
+
+<body>
+
+<script>
+    description("Tests accessing CSSStyleSheet after removing from the document");
+
+    const link_elements = Array.from(document.getElementsByClassName("test-stylesheet"));
+    for (const element of link_elements) {
+        const stylesheet = element.sheet;
+
+        window.document.head.removeChild(element);
+
+        if (element.dataset.accessible === "true") {
+            shouldNotThrow(() => stylesheet.cssRules);
+            shouldNotThrow(() => stylesheet.insertRule('#element2 { margin: 10px; }'));
+            shouldNotThrow(() => stylesheet.deleteRule(0));
+        } else {
+            shouldThrowErrorName(() => stylesheet.cssRules, "SecurityError");
+            shouldThrowErrorName(() => stylesheet.insertRule('body { margin: 10px; } '), "SecurityError");
+            shouldThrowErrorName(() => stylesheet.deleteRule(0), "SecurityError");
+        }
+    }
+</script>
+
+<script src="/resources/js-test-post.js"></script>
+
+</body>
diff --git a/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document-expected.txt b/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document-expected.txt
new file mode 100644
index 000000000000..321f2c3eab2a
--- /dev/null
+++ b/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document-expected.txt
@@ -0,0 +1,24 @@
+Tests accessing @import-ed CSSStyleSheet after
+
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules did not throw exception.
+PASS () => stylesheet.insertRule('#element2 { margin: 10px; }') did not throw exception.
+PASS () => stylesheet.deleteRule(0) did not throw exception.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS () => stylesheet.cssRules threw exception SecurityError: Not allowed to access cross-origin stylesheet.
+PASS () => stylesheet.insertRule('body { margin: 10px; } ') threw exception SecurityError: Not allowed to insert rule into cross-origin stylesheet.
+PASS () => stylesheet.deleteRule(0) threw exception SecurityError: Not allowed to delete rule from cross-origin stylesheet.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document.html b/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document.html
new file mode 100644
index 000000000000..d396755fe6b0
--- /dev/null
+++ b/LayoutTests/http/tests/security/access-imported-cssstylesheet-after-removing-from-document.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+
+<head>
+
+<title>Accessing @import-ed CSSStyleSheet after removing from document</title>
+<script src="/resources/js-test-pre.js"></script>
+
+<style id="local-style">
+    /* Rule 0: same origin */
+    @import url('/security/resources/cssStyle.css');
+
+    /* Rule 1: cross origin */
+    @import url('http://localhost:8000/security/resources/cssStyle.css');
+
+    /* Rule 3: same origin -> same origin */
+    @import url('/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css');
+
+    /* Rule 4: same origin -> cross origin */
+    @import url('/resources/redirect.py?url=http://localhost:8000/security/resources/cssStyle.css');
+
+    /* Rule 5: cross origin -> same origin */
+    @import url('http://localhost:8000/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css');
+
+    /* Rule 6: same origin -> cross origin -> same origin */
+    @import url('http://127.0.0.1:8000/resources/redirect.py?url=http://localhost:8000/resources/redirect.py?url=http://127.0.0.1:8000/security/resources/cssStyle.css');
+</style>
+
+</head>
+
+<body>
+
+<script>
+    description("Tests accessing @import-ed CSSStyleSheet after <style> is removed from the document");
+
+    let local_style = document.getElementById("local-style");
+    let local_stylesheet = local_style.sheet;
+
+    // Remove the styles from the document
+    window.document.head.removeChild(local_style);
+
+    function check_accessible(stylesheet) {
+        shouldNotThrow(() => stylesheet.cssRules);
+        shouldNotThrow(() => stylesheet.insertRule('#element2 { margin: 10px; }'));
+        shouldNotThrow(() => stylesheet.deleteRule(0));
+    }
+
+    function check_inaccessible(stylesheet) {
+        shouldThrowErrorName(() => stylesheet.cssRules, "SecurityError");
+        shouldThrowErrorName(() => stylesheet.insertRule('body { margin: 10px; } '), "SecurityError");
+        shouldThrowErrorName(() => stylesheet.deleteRule(0), "SecurityError");
+    }
+
+    check_accessible(local_stylesheet.rules[0].styleSheet);
+    check_inaccessible(local_stylesheet.rules[1].styleSheet);
+    check_accessible(local_stylesheet.rules[2].styleSheet);
+    check_inaccessible(local_stylesheet.rules[3].styleSheet);
+    check_inaccessible(local_stylesheet.rules[4].styleSheet);
+    check_inaccessible(local_stylesheet.rules[5].styleSheet);
+</script>
+
+<script src="/resources/js-test-post.js"></script>
+
+</body>
+
diff --git a/LayoutTests/http/tests/security/cannot-read-cssrules-redirect-expected.txt b/LayoutTests/http/tests/security/cannot-read-cssrules-redirect-expected.txt
index 6cbdfa1e0175..4d77f44527cc 100644
--- a/LayoutTests/http/tests/security/cannot-read-cssrules-redirect-expected.txt
+++ b/LayoutTests/http/tests/security/cannot-read-cssrules-redirect-expected.txt
@@ -6,10 +6,8 @@ Test begins.
 exception thrown for cssRules: SecurityError: Not allowed to access cross-origin stylesheet
 exception thrown for rules: SecurityError: Not allowed to access cross-origin stylesheet
 == Cross-Origin to Same-Origin, no-cors mode ==
-cssRules: [object CSSRuleList]
-cssRules length: 1
-rules: [object CSSRuleList]
-rules length: 1
+exception thrown for cssRules: SecurityError: Not allowed to access cross-origin stylesheet
+exception thrown for rules: SecurityError: Not allowed to access cross-origin stylesheet
 == Same-Origin to Same-Origin, cors mode ==
 cssRules: [object CSSRuleList]
 cssRules length: 1
diff --git a/LayoutTests/imported/w3c/web-platform-tests/service-workers/service-worker/fetch-request-css-cross-origin.https-expected.txt b/LayoutTests/imported/w3c/web-platform-tests/service-workers/service-worker/fetch-request-css-cross-origin.https-expected.txt
index da5202f054ab..f1cea4c665be 100644
--- a/LayoutTests/imported/w3c/web-platform-tests/service-workers/service-worker/fetch-request-css-cross-origin.https-expected.txt
+++ b/LayoutTests/imported/w3c/web-platform-tests/service-workers/service-worker/fetch-request-css-cross-origin.https-expected.txt
@@ -1,6 +1,6 @@
 
 PASS setup global state
 PASS MIME checking of CSS resources fetched via service worker when Content-Type is not set.
-FAIL Same-origin policy for access to CSS resources fetched via service worker promise_test: Unhandled rejection with value: object "SecurityError: Not allowed to access cross-origin stylesheet"
+PASS Same-origin policy for access to CSS resources fetched via service worker
 PASS cleanup global state
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.