Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionMitigation bypass in the DOM: Service Workers component
ComponentDOM
Bug ClassLogic Error
Tracker2041864
Fix commit9f80d0a7fd59 (firefox) +87/-29
CISA KEVNot listed
CreditedYaqoub Aldurayhim
Disclosed2026-07-21

Background

Service Worker / `ChromeWorker`
a background JavaScript execution context; a ChromeWorker runs with elevated (system) privileges inside the browser.
`URI_IS_UI_RESOURCE`
a protocol-handler flag marking schemes that serve browser UI resources, used here to decide whether a URI is a trusted place to load a privileged worker script from.
`page-icon:` / `moz-icon:`
internal image/UI data protocols that carry URI_IS_UI_RESOURCE but return attacker-influenced favicon bytes stored verbatim by Places.
`nsContentSecurityUtils::IsTrustedScheme`
a helper that admits only genuine script-bearing chrome schemes such as chrome: and resource://.

Root Cause Analysis

Three worker-loading checks — GetCommonSecFlags (for DebuggerScript), WorkerLoadInfo::GetPrincipalsAndLoadGroupFromChannel (assigning the system principal), and WorkerLoadInfo::PrincipalURIMatchesScriptURL (validating a system-principal script URL) — all used NS_URIChainHasFlags with URI_IS_UI_RESOURCE to gate whether a URI counted as a trusted local resource. The violated invariant is that a privileged worker script must originate from an actual script-bearing chrome scheme; URI_IS_UI_RESOURCE, however, is also set on image/UI data protocols like page-icon: and moz-icon:, which are not script sources. Because page-icon: returns favicon bytes stored verbatim by Places (which only checks for a <svg substring), an attacker could supply bytes that are simultaneously a valid SVG favicon and valid JavaScript, then have them loaded as a system-principal worker script.

The fix replaces every URI_IS_UI_RESOURCE flag test with nsContentSecurityUtils::IsTrustedScheme, which restricts acceptance to real trusted script schemes and rejects page-icon: and its siblings. This closes the gap between “is a UI resource” and “is a valid script source,” so the load is refused synchronously with NS_ERROR_DOM_SECURITY_ERR / NS_ERROR_DOM_BAD_URI.

Key insight
The single mistake was conflating “carries the URI_IS_UI_RESOURCE UI-resource flag” with “is a trusted script source,” letting non-script data protocols such as page-icon: slip through; the fix narrows the gate to nsContentSecurityUtils::IsTrustedScheme, which admits only genuine script-bearing chrome schemes.

Attack Path

  1. Plant a malicious favicon The attacker serves an SVG favicon whose bytes are also valid JavaScript (a leading /*<svg*/ block comment hides the markup from the JS parser).
  2. Get it stored verbatim Places stores the favicon bytes verbatim, and the corresponding page-icon: URL returns those attacker-controlled bytes unchanged.
  3. Load it as a privileged worker Privileged code constructs a worker (e.g. ChromeWorker) pointing at the page-icon: URL, which passes the old URI_IS_UI_RESOURCE check.
  4. Execute with system principal The bytes load as a system-principal worker script and run, giving attacker JavaScript execution in a privileged context.

Impact Assessment

Successful exploitation bypasses a mitigation to run attacker-influenced JavaScript as a system-principal (chrome) worker, gaining privileged in-browser code execution outside the web-content sandbox. It requires the attacker to seed a crafted favicon into Places and privileged code to be induced into loading a page-icon: (or similar) URL as a worker script; the impact is rated moderate, consistent with a mitigation-bypass logic error rather than a direct remote primitive.

Changed Functions

FunctionChangeNotes
if
dom/workers/ScriptLoader.cpp
modified
if
dom/workers/WorkerLoadInfo.cpp
modified
add_task
dom/workers/test/browser_system_worker_page_icon_rejected.js
modified

Files Changed

  • dom/workers/ScriptLoader.cpp
  • dom/workers/WorkerLoadInfo.cpp
  • dom/workers/test/browser.toml
  • dom/workers/test/browser_system_worker_page_icon_rejected.js

Audit Directions

  • `URI_IS_UI_RESOURCE` as a trust gate
    Audit every NS_URIChainHasFlags(..., URI_IS_UI_RESOURCE, ...) use that gates code loading or principal assignment; the flag denotes UI resources, not script sources, so prefer nsContentSecurityUtils::IsTrustedScheme.
  • Verbatim-served data protocols
    Review internal schemes like page-icon: and moz-icon: that return externally-influenced bytes, and ensure they can never reach script or module loaders.
  • Scheme vs. capability conflation
    Look for checks that infer a strong capability (script execution, system principal) from a broad or loosely-related scheme flag rather than an explicit allowlist of trusted schemes.
diff --git a/dom/workers/ScriptLoader.cpp b/dom/workers/ScriptLoader.cpp
index c6d587f0f8c..47590f39ec1 100644
--- a/dom/workers/ScriptLoader.cpp
+++ b/dom/workers/ScriptLoader.cpp
@@ -42,6 +42,7 @@
 #include "nsComponentManagerUtils.h"
 #include "nsContentPolicyUtils.h"
 #include "nsContentSecurityManager.h"
+#include "nsContentSecurityUtils.h"
 #include "nsContentUtils.h"
 #include "nsDocShellCID.h"
 #include "nsError.h"
@@ -57,7 +58,6 @@
 #include "nsIOutputStream.h"
 #include "nsIPipe.h"
 #include "nsIPrincipal.h"
-#include "nsIProtocolHandler.h"
 #include "nsIScriptError.h"
 #include "nsIScriptSecurityManager.h"
 #include "nsIStreamListenerTee.h"
@@ -364,15 +364,11 @@ nsresult GetCommonSecFlags(bool aIsMainScript, nsIURI* uri,
   }
 
   if (aWorkerScriptType == DebuggerScript) {
-    // A DebuggerScript needs to be a local resource like chrome: or resource:
-    bool isUIResource = false;
-    nsresult rv = NS_URIChainHasFlags(
-        uri, nsIProtocolHandler::URI_IS_UI_RESOURCE, &isUIResource);
-    if (NS_WARN_IF(NS_FAILED(rv))) {
-      return rv;
-    }
-
-    if (!isUIResource) {
+    // A DebuggerScript needs to be a chrome script resource like chrome: or
+    // resource:. We restrict it to those trusted schemes rather than the broad
+    // URI_IS_UI_RESOURCE flag, which image/UI data protocols (page-icon:,
+    // moz-icon:, ...) also carry and must never be loaded as worker scripts.
+    if (!nsContentSecurityUtils::IsTrustedScheme(uri)) {
       return NS_ERROR_DOM_SECURITY_ERR;
     }
 
diff --git a/dom/workers/WorkerLoadInfo.cpp b/dom/workers/WorkerLoadInfo.cpp
index 99969d2995f..6d448f1021a 100644
--- a/dom/workers/WorkerLoadInfo.cpp
+++ b/dom/workers/WorkerLoadInfo.cpp
@@ -15,12 +15,12 @@
 #include "mozilla/dom/nsCSPUtils.h"
 #include "mozilla/ipc/BackgroundUtils.h"
 #include "mozilla/ipc/PBackgroundSharedTypes.h"
+#include "nsContentSecurityUtils.h"
 #include "nsContentUtils.h"
 #include "nsIBrowserChild.h"
 #include "nsIContentSecurityPolicy.h"
 #include "nsICookieJarSettings.h"
 #include "nsINetworkInterceptController.h"
-#include "nsIProtocolHandler.h"
 #include "nsIReferrerInfo.h"
 #include "nsNetUtil.h"
 #include "nsScriptSecurityManager.h"
@@ -195,17 +195,14 @@ nsresult WorkerLoadInfo::GetPrincipalsAndLoadGroupFromChannel(
       rv = NS_GetFinalChannelURI(aChannel, getter_AddRefs(finalURI));
       NS_ENSURE_SUCCESS(rv, rv);
 
-      // See if this is a resource URI. Since JSMs usually come from
-      // resource:// URIs we're currently considering all URIs with the
-      // URI_IS_UI_RESOURCE flag as valid for creating privileged workers.
-      bool isResource;
-      rv = NS_URIChainHasFlags(finalURI, nsIProtocolHandler::URI_IS_UI_RESOURCE,
-                               &isResource);
-      NS_ENSURE_SUCCESS(rv, rv);
-
-      if (isResource) {
-        // Assign the system principal to the resource:// worker only if it
-        // was loaded from code using the system principal.
+      // Privileged workers' scripts come from script-bearing chrome schemes
+      // such as resource:// (where JSMs live). We restrict them to those
+      // trusted schemes rather than the broad URI_IS_UI_RESOURCE flag, which
+      // image/UI data protocols (page-icon:, moz-icon:, ...) also carry and
+      // must never be loaded as worker scripts.
+      if (nsContentSecurityUtils::IsTrustedScheme(finalURI)) {
+        // Assign the system principal to the worker only if it was loaded from
+        // code using the system principal.
         channelPrincipal = mLoadingPrincipal;
         channelPartitionedPrincipal = mLoadingPrincipal;
       } else {
@@ -293,18 +290,14 @@ bool WorkerLoadInfo::PrincipalURIMatchesScriptURL() {
   nsresult rv = mBaseURI->GetScheme(scheme);
   NS_ENSURE_SUCCESS(rv, false);
 
-  // A system principal must either be a blob URL or a resource JSM.
+  // A system principal must either be a blob URL or a chrome script resource
+  // (e.g. a resource:// JSM).
   if (mPrincipal->IsSystemPrincipal()) {
     if (scheme == "blob"_ns) {
       return true;
     }
 
-    bool isResource = false;
-    nsresult rv = NS_URIChainHasFlags(
-        mBaseURI, nsIProtocolHandler::URI_IS_UI_RESOURCE, &isResource);
-    NS_ENSURE_SUCCESS(rv, false);
-
-    return isResource;
+    return nsContentSecurityUtils::IsTrustedScheme(mBaseURI);
   }
 
   // A null principal can occur for a data URL worker script or a blob URL
diff --git a/dom/workers/test/browser.toml b/dom/workers/test/browser.toml
index 102e4aaf636..b19acf8a6da 100644
--- a/dom/workers/test/browser.toml
+++ b/dom/workers/test/browser.toml
@@ -55,6 +55,8 @@ support-files = [
   "server_fetch_synthetic.sjs",
 ]
 
+["browser_system_worker_page_icon_rejected.js"]
+
 ["browser_worker_use_counters.js"]
 support-files = [
   "file_use_counter_worker.html",
diff --git a/dom/workers/test/browser_system_worker_page_icon_rejected.js b/dom/workers/test/browser_system_worker_page_icon_rejected.js
new file mode 100644
index 00000000000..a9f54d730ab
--- /dev/null
+++ b/dom/workers/test/browser_system_worker_page_icon_rejected.js
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+  PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+// These bytes are simultaneously valid JavaScript (the leading block comment
+// hides the markup from the JS parser) and a payload that Places stores
+// verbatim as an "SVG" favicon (it only checks for a "<svg" substring). If a
+// worker script load from page-icon: were *not* blocked, this would execute
+// and post "executed" back. The fix must prevent the load entirely.
+const WORKER_SOURCE = `/*<svg*/
+self.postMessage("executed:" + (typeof ChromeUtils));
+`;
+
+const PAGE_URL = "https://example.com/system-worker-page-icon-test/";
+const FAVICON_URL = "https://example.com/system-worker-page-icon-test/icon.svg";
+const PAGE_ICON_URL = "page-icon:" + PAGE_URL;
+
+add_task(async function test_system_worker_cannot_load_page_icon_script() {
+  registerCleanupFunction(async () => {
+    await PlacesUtils.history.clear();
+  });
+
+  // Seed Places with an SVG favicon whose bytes are valid JavaScript.
+  await PlacesTestUtils.addVisits(PAGE_URL);
+  await PlacesTestUtils.setFaviconForPage(
+    PAGE_URL,
+    FAVICON_URL,
+    "data:image/svg+xml;base64," + btoa(WORKER_SOURCE)
+  );
+
+  // Sanity check: page-icon: returns our attacker-controlled bytes verbatim, so
+  // absent the fix a worker loading this URL would execute them.
+  const favicon = await PlacesTestUtils.getFaviconForPage(PAGE_URL);
+  is(
+    favicon?.rawData &&
+      new TextDecoder().decode(Uint8Array.from(favicon.rawData)),
+    WORKER_SOURCE,
+    "page-icon: stores the attacker-controlled bytes verbatim"
+  );
+
+  // The actual test: a system-principal (Chrome) worker must refuse to load a
+  // page-icon: script. The script load is rejected synchronously during
+  // construction (NS_ERROR_DOM_BAD_URI), so the constructor throws a
+  // SecurityError rather than ever executing the worker.
+  let error;
+  try {
+    new ChromeWorker(PAGE_ICON_URL);
+  } catch (e) {
+    error = e;
+  }
+
+  ok(
+    error,
+    "A system-principal worker must refuse to load a page-icon: script"
+  );
+  ok(
+    DOMException.isInstance(error) && error.name === "SecurityError",
+    "ChromeWorker construction throws SecurityError for a page-icon: script: " +
+      error
+  );
+});
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/dom/workers/test/browser.toml b/dom/workers/test/browser.toml
index 102e4aaf636..b19acf8a6da 100644
--- a/dom/workers/test/browser.toml
+++ b/dom/workers/test/browser.toml
@@ -55,6 +55,8 @@ support-files = [
   "server_fetch_synthetic.sjs",
 ]
 
+["browser_system_worker_page_icon_rejected.js"]
+
 ["browser_worker_use_counters.js"]
 support-files = [
   "file_use_counter_worker.html",
diff --git a/dom/workers/test/browser_system_worker_page_icon_rejected.js b/dom/workers/test/browser_system_worker_page_icon_rejected.js
new file mode 100644
index 00000000000..a9f54d730ab
--- /dev/null
+++ b/dom/workers/test/browser_system_worker_page_icon_rejected.js
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+  PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+// These bytes are simultaneously valid JavaScript (the leading block comment
+// hides the markup from the JS parser) and a payload that Places stores
+// verbatim as an "SVG" favicon (it only checks for a "<svg" substring). If a
+// worker script load from page-icon: were *not* blocked, this would execute
+// and post "executed" back. The fix must prevent the load entirely.
+const WORKER_SOURCE = `/*<svg*/
+self.postMessage("executed:" + (typeof ChromeUtils));
+`;
+
+const PAGE_URL = "https://example.com/system-worker-page-icon-test/";
+const FAVICON_URL = "https://example.com/system-worker-page-icon-test/icon.svg";
+const PAGE_ICON_URL = "page-icon:" + PAGE_URL;
+
+add_task(async function test_system_worker_cannot_load_page_icon_script() {
+  registerCleanupFunction(async () => {
+    await PlacesUtils.history.clear();
+  });
+
+  // Seed Places with an SVG favicon whose bytes are valid JavaScript.
+  await PlacesTestUtils.addVisits(PAGE_URL);
+  await PlacesTestUtils.setFaviconForPage(
+    PAGE_URL,
+    FAVICON_URL,
+    "data:image/svg+xml;base64," + btoa(WORKER_SOURCE)
+  );
+
+  // Sanity check: page-icon: returns our attacker-controlled bytes verbatim, so
+  // absent the fix a worker loading this URL would execute them.
+  const favicon = await PlacesTestUtils.getFaviconForPage(PAGE_URL);
+  is(
+    favicon?.rawData &&
+      new TextDecoder().decode(Uint8Array.from(favicon.rawData)),
+    WORKER_SOURCE,
+    "page-icon: stores the attacker-controlled bytes verbatim"
+  );
+
+  // The actual test: a system-principal (Chrome) worker must refuse to load a
+  // page-icon: script. The script load is rejected synchronously during
+  // construction (NS_ERROR_DOM_BAD_URI), so the constructor throws a
+  // SecurityError rather than ever executing the worker.
+  let error;
+  try {
+    new ChromeWorker(PAGE_ICON_URL);
+  } catch (e) {
+    error = e;
+  }
+
+  ok(
+    error,
+    "A system-principal worker must refuse to load a page-icon: script"
+  );
+  ok(
+    DOMException.isInstance(error) && error.name === "SecurityError",
+    "ChromeWorker construction throws SecurityError for a page-icon: script: " +
+      error
+  );
+});
Loading diff…