Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionMemory safety bug fixed in Firefox 152
ComponentCore
Bug ClassLogic Error
Tracker2038133
Fix commitc406de81099c (firefox) +26/-23
CISA KEVNot listed
CreditedAtsushi Sada
Disclosed2026-06-16

Background

Style Editor
a Firefox DevTools panel that lets a developer view and edit a page’s stylesheets and save the edits back to a file.
`StyleSheetEditor`
the devtools/client/styleeditor/StyleSheetEditor.sys.mjs class that backs one editable stylesheet in the Style Editor.
`showFilePicker`
a helper that, when passed a non-null file argument, writes directly to that target and otherwise opens an interactive save dialog for the user to choose a destination.
`file://` scheme
a URL scheme identifying a resource on the local filesystem, detected here via Services.io.extractScheme.

Root Cause Analysis

In the StyleSheetEditor constructor, this._styleSheetFilePath was initialized from this.styleSheet.href whenever Services.io.extractScheme(this.styleSheet.href) returned "file", silently adopting the stylesheet’s own source location as a save target. The saveToFile method then called showFilePicker(file || this._styleSheetFilePath, ...), so when no explicit file was supplied the edited contents were written directly to the source path, bypassing the interactive picker. This violated the invariant that a filesystem write must be driven by an explicit, user-chosen destination rather than one derived implicitly from an inspected resource’s URL.

The fix removes the _styleSheetFilePath field and its constructor logic and calls showFilePicker(file, ...), so absent an explicit target the picker prompts the user and no automatic overwrite of the source file:// path occurs.

The added test asserts editor.savedFile starts as null and is only set after an explicit file is passed, confirming the source file is no longer pre-populated as the save destination.

Key insight
The single mistake was implicitly treating a file://-sourced stylesheet’s own URL as its save destination, which let an edit-and-save write straight back to disk without an explicit user-selected target; the fix removes that derived path so saving always requires an explicit file or falls through to the interactive picker.

Attack Path

  1. Load a local stylesheet Content loaded from a file:// origin references a stylesheet whose href uses the file scheme, so StyleSheetEditor records that path in _styleSheetFilePath.
  2. Open the Style Editor The stylesheet is opened in DevTools, where the constructor has already bound the source path as the implicit save target.
  3. Trigger an edit and save Content is modified and saveToFile(null, ...) runs, causing showFilePicker to receive the derived _styleSheetFilePath instead of null.
  4. Bypass the picker Because a non-null path is passed, the write proceeds directly to the source file rather than prompting the user to confirm a destination.

Impact Assessment

An attacker gains an unexpected direct write of edited stylesheet contents to a local file:// path without the confirming file-picker interaction, within the privileged DevTools/parent context that performs filesystem access. Preconditions are that the stylesheet be sourced from a file:// URL and that the Style Editor be used to edit and save it, so exploitation depends on local-file DevTools workflows rather than a remote drive-by.

Changed Functions

FunctionChangeNotes
add_task
devtools/client/styleeditor/test/browser_styleeditor_filesave.js
modified

Files Changed

  • devtools/client/styleeditor/StyleSheetEditor.sys.mjs
  • devtools/client/styleeditor/test/browser_styleeditor_filesave.js
  • devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js

Audit Directions

  • Implicit save targets
    Flag any code that derives a filesystem write destination from an inspected resource’s own URL or href rather than from an explicit, user-chosen path.
  • Picker bypass via optionals
    Review showFilePicker and similar helpers for callers passing a non-null default that skips the confirmation dialog, especially when that default originates from untrusted or inspected content.
  • `file://` scheme handling in DevTools
    Audit extractScheme-gated logic where a file scheme unlocks a direct-write shortcut, ensuring local-file paths never become automatic overwrite targets.
diff --git a/devtools/client/styleeditor/StyleSheetEditor.sys.mjs b/devtools/client/styleeditor/StyleSheetEditor.sys.mjs
index 4b74a5b2114..26d90bcb523 100644
--- a/devtools/client/styleeditor/StyleSheetEditor.sys.mjs
+++ b/devtools/client/styleeditor/StyleSheetEditor.sys.mjs
@@ -113,14 +113,6 @@ export class StyleSheetEditor extends EventEmitter {
       },
     };
 
-    this._styleSheetFilePath = null;
-    if (
-      this.styleSheet.href &&
-      Services.io.extractScheme(this.styleSheet.href) == "file"
-    ) {
-      this._styleSheetFilePath = this.styleSheet.href;
-    }
-
     this.onPropertyChange = this.onPropertyChange.bind(this);
     this.onAtRulesChanged = this.onAtRulesChanged.bind(this);
     this.checkLinkedFileForChanges = this.checkLinkedFileForChanges.bind(this);
@@ -823,13 +815,7 @@ export class StyleSheetEditor extends EventEmitter {
         ? PathUtils.filename(this._friendlyName)
         : this._friendlyName;
     }
-    showFilePicker(
-      file || this._styleSheetFilePath,
-      true,
-      this._window,
-      onFile,
-      defaultName
-    );
+    showFilePicker(file, true, this._window, onFile, defaultName);
   }
 
   /**
diff --git a/devtools/client/styleeditor/test/browser_styleeditor_filesave.js b/devtools/client/styleeditor/test/browser_styleeditor_filesave.js
index d59137af252..d4624b2bc86 100644
--- a/devtools/client/styleeditor/test/browser_styleeditor_filesave.js
+++ b/devtools/client/styleeditor/test/browser_styleeditor_filesave.js
@@ -9,7 +9,7 @@ const TESTCASE_URI_CSS = TEST_BASE_HTTP + "simple.css";
 
 add_task(async function () {
   const htmlFile = await copy(TESTCASE_URI_HTML, "simple.html");
-  await copy(TESTCASE_URI_CSS, "simple.css");
+  const cssFile = await copy(TESTCASE_URI_CSS, "simple.css");
   const uri = Services.io.newFileURI(htmlFile);
   const filePath = uri.resolve("");
 
@@ -18,6 +18,12 @@ add_task(async function () {
   const editor = ui.editors[0];
   await editor.getSourceEditor();
 
+  is(
+    editor.savedFile,
+    null,
+    "savedFile should not be pre-populated from the source file"
+  );
+
   info("Editing the style sheet.");
   let dirty = editor.sourceEditor.once("dirty-change");
   const beginCursor = { line: 0, ch: 0 };
@@ -31,11 +37,13 @@ add_task(async function () {
     "Star icon is present in the corresponding summary."
   );
 
-  info("Saving the changes.");
+  info(
+    "Saving the changes with an explicit file (simulating a user-chosen save location)."
+  );
   dirty = editor.sourceEditor.once("dirty-change");
 
-  editor.saveToFile(null, function (file) {
-    ok(file, "file should get saved directly when using a file:// URI");
+  editor.saveToFile(cssFile, function (file) {
+    ok(file, "file should get saved when explicitly passing a file");
   });
 
   await dirty;
@@ -45,6 +53,12 @@ add_task(async function () {
     !editor.summary.classList.contains("unsaved"),
     "Star icon is not present in the corresponding summary."
   );
+
+  is(
+    editor.savedFile?.path,
+    cssFile.path,
+    "savedFile should now be set on the editor"
+  );
 });
 
 function copy(srcChromeURL, destFileName) {
diff --git a/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js b/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js
index 582f9f0a43c..8de40a02e37 100644
--- a/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js
+++ b/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js
@@ -26,7 +26,10 @@ add_task(async function () {
     "sourcemap-css",
     "sourcemaps.css",
   ]);
-  await copy(TESTCASE_URI_SCSS, ["sourcemap-sass", "sourcemaps.scss"]);
+  const SCSSFile = await copy(TESTCASE_URI_SCSS, [
+    "sourcemap-sass",
+    "sourcemaps.scss",
+  ]);
   await copy(TESTCASE_URI_MAP, ["sourcemap-css", "sourcemaps.css.map"]);
   await copy(TESTCASE_URI_REG_CSS, ["simple.css"]);
 
@@ -59,7 +62,7 @@ add_task(async function () {
 
   // Edit and save Sass in the editor. This will start off a file-watching
   // process waiting for the CSS file to change.
-  await editSCSS(editor);
+  await editSCSS(SCSSFile, editor);
 
   // We can't run Sass or another compiler, so we fake it by just
   // directly changing the CSS file.
@@ -76,11 +79,11 @@ add_task(async function () {
   is(editor.sourceEditor.getText(), CSS_TEXT, "edits remain applied");
 });
 
-function editSCSS(editor) {
+function editSCSS(SCSSFile, editor) {
   return new Promise(resolve => {
     editor.sourceEditor.setText(CSS_TEXT);
 
-    editor.saveToFile(null, function (file) {
+    editor.saveToFile(SCSSFile, function (file) {
       ok(file, "Scss file should be saved");
       resolve();
     });
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/devtools/client/styleeditor/test/browser_styleeditor_filesave.js b/devtools/client/styleeditor/test/browser_styleeditor_filesave.js
index d59137af252..d4624b2bc86 100644
--- a/devtools/client/styleeditor/test/browser_styleeditor_filesave.js
+++ b/devtools/client/styleeditor/test/browser_styleeditor_filesave.js
@@ -9,7 +9,7 @@ const TESTCASE_URI_CSS = TEST_BASE_HTTP + "simple.css";
 
 add_task(async function () {
   const htmlFile = await copy(TESTCASE_URI_HTML, "simple.html");
-  await copy(TESTCASE_URI_CSS, "simple.css");
+  const cssFile = await copy(TESTCASE_URI_CSS, "simple.css");
   const uri = Services.io.newFileURI(htmlFile);
   const filePath = uri.resolve("");
 
@@ -18,6 +18,12 @@ add_task(async function () {
   const editor = ui.editors[0];
   await editor.getSourceEditor();
 
+  is(
+    editor.savedFile,
+    null,
+    "savedFile should not be pre-populated from the source file"
+  );
+
   info("Editing the style sheet.");
   let dirty = editor.sourceEditor.once("dirty-change");
   const beginCursor = { line: 0, ch: 0 };
@@ -31,11 +37,13 @@ add_task(async function () {
     "Star icon is present in the corresponding summary."
   );
 
-  info("Saving the changes.");
+  info(
+    "Saving the changes with an explicit file (simulating a user-chosen save location)."
+  );
   dirty = editor.sourceEditor.once("dirty-change");
 
-  editor.saveToFile(null, function (file) {
-    ok(file, "file should get saved directly when using a file:// URI");
+  editor.saveToFile(cssFile, function (file) {
+    ok(file, "file should get saved when explicitly passing a file");
   });
 
   await dirty;
@@ -45,6 +53,12 @@ add_task(async function () {
     !editor.summary.classList.contains("unsaved"),
     "Star icon is not present in the corresponding summary."
   );
+
+  is(
+    editor.savedFile?.path,
+    cssFile.path,
+    "savedFile should now be set on the editor"
+  );
 });
 
 function copy(srcChromeURL, destFileName) {
diff --git a/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js b/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js
index 582f9f0a43c..8de40a02e37 100644
--- a/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js
+++ b/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js
@@ -26,7 +26,10 @@ add_task(async function () {
     "sourcemap-css",
     "sourcemaps.css",
   ]);
-  await copy(TESTCASE_URI_SCSS, ["sourcemap-sass", "sourcemaps.scss"]);
+  const SCSSFile = await copy(TESTCASE_URI_SCSS, [
+    "sourcemap-sass",
+    "sourcemaps.scss",
+  ]);
   await copy(TESTCASE_URI_MAP, ["sourcemap-css", "sourcemaps.css.map"]);
   await copy(TESTCASE_URI_REG_CSS, ["simple.css"]);
 
@@ -59,7 +62,7 @@ add_task(async function () {
 
   // Edit and save Sass in the editor. This will start off a file-watching
   // process waiting for the CSS file to change.
-  await editSCSS(editor);
+  await editSCSS(SCSSFile, editor);
 
   // We can't run Sass or another compiler, so we fake it by just
   // directly changing the CSS file.
@@ -76,11 +79,11 @@ add_task(async function () {
   is(editor.sourceEditor.getText(), CSS_TEXT, "edits remain applied");
 });
 
-function editSCSS(editor) {
+function editSCSS(SCSSFile, editor) {
   return new Promise(resolve => {
     editor.sourceEditor.setText(CSS_TEXT);
 
-    editor.saveToFile(null, function (file) {
+    editor.saveToFile(SCSSFile, function (file) {
       ok(file, "Scss file should be saved");
       resolve();
     });
Loading diff…