Low firefox Logic Error 🔧 Commit mapped

Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionIf a user saved a response from the Network tab in Devtools using the Save As context menu option, that file may not have been saved with the <code>.download</code> file extension. This could have led to the user inadvertently running a malicious executable.
ComponentCore
Bug ClassLogic Error
Tracker1961777
Fix commitbbae8b3bd018 (firefox) +212/-31
CISA KEVNot listed
CreditedAmeen Basha M K
Disclosed2025-06-24

Changed Functions

FunctionChangeNotes
registerCleanupFunction
devtools/client/netmonitor/test/browser_net_save_response_as.js
modified
add_task
devtools/client/netmonitor/test/browser_net_save_response_as_sanitization.js
modified
registerCleanupFunction
devtools/client/netmonitor/test/browser_net_save_response_as_sanitization.js
modified

Files Changed

  • devtools/client/netmonitor/test/browser.toml
  • devtools/client/netmonitor/test/browser_net_save_response_as.js
  • devtools/client/netmonitor/test/browser_net_save_response_as_sanitization.js
  • devtools/client/netmonitor/test/head.js
  • devtools/shared/DevToolsUtils.js
diff --git a/devtools/client/netmonitor/test/browser.toml b/devtools/client/netmonitor/test/browser.toml
index 8e9d4a6f9d4..c8679d810d2 100644
--- a/devtools/client/netmonitor/test/browser.toml
+++ b/devtools/client/netmonitor/test/browser.toml
@@ -436,6 +436,8 @@ fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and
 
 ["browser_net_save_response_as.js"]
 
+["browser_net_save_response_as_sanitization.js"]
+
 ["browser_net_script_cache.js"]
 
 ["browser_net_search-results.js"]
diff --git a/devtools/client/netmonitor/test/browser_net_save_response_as.js b/devtools/client/netmonitor/test/browser_net_save_response_as.js
index 638fb50794c..83600463ddf 100644
--- a/devtools/client/netmonitor/test/browser_net_save_response_as.js
+++ b/devtools/client/netmonitor/test/browser_net_save_response_as.js
@@ -41,26 +41,15 @@ add_task(async function () {
 
   registerCleanupFunction(function () {
     MockFilePicker.cleanup();
-    destDir.remove(true);
   });
 
   // Select gzip request.
 
-  info("Open the context menu");
-
-  EventUtils.sendMouseEvent(
-    { type: "mousedown" },
-    document.querySelectorAll(".request-list-item")[6]
-  );
-
-  EventUtils.sendMouseEvent(
-    { type: "contextmenu" },
+  await triggerSaveResponseAs(
+    monitor,
     document.querySelectorAll(".request-list-item")[6]
   );
 
-  info("Open the save dialog");
-  await selectContextMenuItem(monitor, "request-list-context-save-response-as");
-
   info("Wait for the save dialog to close");
   const savedPath = await saveDialogClosedPromise;
 
@@ -69,14 +58,7 @@ add_task(async function () {
 
   is(savedPath, expectedFile.path, "Response was saved to correct path");
 
-  info("Wait for the downloaded file to be fully saved to disk: " + savedPath);
-  await TestUtils.waitForCondition(async () => {
-    if (!(await IOUtils.exists(savedPath))) {
-      return false;
-    }
-    const { size } = await IOUtils.stat(savedPath);
-    return size > 0;
-  });
+  await waitForFileSavedToDisk(savedPath);
 
   const buffer = await IOUtils.read(savedPath);
   const savedFileContent = new TextDecoder().decode(buffer);
@@ -91,12 +73,3 @@ add_task(async function () {
 
   await teardown(monitor);
 });
-
-function createTemporarySaveDirectory() {
-  const saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
-  saveDir.append("testsavedir");
-  if (!saveDir.exists()) {
-    saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
-  }
-  return saveDir;
-}
diff --git a/devtools/client/netmonitor/test/browser_net_save_response_as_sanitization.js b/devtools/client/netmonitor/test/browser_net_save_response_as_sanitization.js
new file mode 100644
index 00000000000..2e80f7f3549
--- /dev/null
+++ b/devtools/client/netmonitor/test/browser_net_save_response_as_sanitization.js
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window.browsingContext);
+
+/**
+ * Tests that filenames are sanitized when using Save Response As
+ */
+
+function setupTestServer() {
+  const httpServer = createTestHTTPServer();
+  httpServer.registerContentType("html", "text/html");
+
+  httpServer.registerPathHandler("/index.html", function (request, response) {
+    response.setStatusLine(request.httpVersion, 200, "OK");
+    response.write(`<!DOCTYPE html>
+    <html><body><h1>Test sanitization for save response as
+    <script>fetch("test.url");</script>
+    <script>fetch("test2.url");</script>
+    `);
+  });
+
+  httpServer.registerPathHandler("/test.url", function (request, response) {
+    response.setStatusLine(request.httpVersion, 200, "OK");
+    response.setHeader("Content-Type", "text/plain", false);
+    response.write("dummy content");
+  });
+  httpServer.registerPathHandler("/test2.url", function (request, response) {
+    response.setStatusLine(request.httpVersion, 200, "OK");
+    response.setHeader("Content-Type", "text/plain", false);
+    response.write("dummy content 2");
+  });
+
+  return httpServer;
+}
+
+add_task(async function () {
+  const httpServer = setupTestServer();
+  const port = httpServer.identity.primaryPort;
+
+  const { monitor } = await initNetMonitor(
+    `http://localhost:${port}/index.html`,
+    {
+      requestCount: 3,
+    }
+  );
+
+  info("Starting test... ");
+  const { document } = monitor.panelWin;
+
+  info("Reload the browser to show the 2 requests for the page");
+  const networkEvent = waitForNetworkEvents(monitor, 3);
+  await reloadBrowser();
+  await networkEvent;
+
+  // Create the folder the gzip file will be saved into
+  const destDir = createTemporarySaveDirectory();
+  let destFile;
+
+  // Prepare the MockFilePicker
+  MockFilePicker.displayDirectory = destDir;
+  registerCleanupFunction(function () {
+    MockFilePicker.cleanup();
+  });
+
+  info("Prepare a file picker mock which will use the default filename");
+  let saveDialogClosedPromise = new Promise(resolve => {
+    MockFilePicker.showCallback = function (fp) {
+      info("MockFilePicker showCallback - preserve .download extension");
+      const fileName = fp.defaultString;
+      destFile = destDir.clone();
+      destFile.append(fileName);
+      MockFilePicker.setFiles([destFile]);
+
+      resolve(destFile.path);
+    };
+  });
+
+  info("Save response as for the test.url request");
+  await triggerSaveResponseAs(
+    monitor,
+    document.querySelectorAll(".request-list-item")[1]
+  );
+
+  info("Wait for the save dialog to close");
+  const filePickerPath = await saveDialogClosedPromise;
+
+  const expectedFile = destDir.clone();
+  expectedFile.append("test.url.download");
+  is(
+    filePickerPath,
+    expectedFile.path,
+    "File picker default filename was set to the expected value"
+  );
+
+  await waitForFileSavedToDisk(expectedFile.path);
+
+  info("Prepare a file picker mock which will override the default filename");
+  saveDialogClosedPromise = new Promise(resolve => {
+    MockFilePicker.showCallback = function (fp) {
+      info("MockFilePicker showCallback - strip .download extension");
+      const fileName = fp.defaultString;
+      destFile = destDir.clone();
+      destFile.append(fileName.replace(".download", ""));
+      MockFilePicker.setFiles([destFile]);
+
+      resolve(destFile.path);
+    };
+  });
+
+  info("Save response as for test2.url");
+  await triggerSaveResponseAs(
+    monitor,
+    document.querySelectorAll(".request-list-item")[2]
+  );
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/devtools/client/netmonitor/test/browser.toml b/devtools/client/netmonitor/test/browser.toml
index 8e9d4a6f9d4..c8679d810d2 100644
--- a/devtools/client/netmonitor/test/browser.toml
+++ b/devtools/client/netmonitor/test/browser.toml
@@ -436,6 +436,8 @@ fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and
 
 ["browser_net_save_response_as.js"]
 
+["browser_net_save_response_as_sanitization.js"]
+
 ["browser_net_script_cache.js"]
 
 ["browser_net_search-results.js"]
diff --git a/devtools/client/netmonitor/test/browser_net_save_response_as.js b/devtools/client/netmonitor/test/browser_net_save_response_as.js
index 638fb50794c..83600463ddf 100644
--- a/devtools/client/netmonitor/test/browser_net_save_response_as.js
+++ b/devtools/client/netmonitor/test/browser_net_save_response_as.js
@@ -41,26 +41,15 @@ add_task(async function () {
 
   registerCleanupFunction(function () {
     MockFilePicker.cleanup();
-    destDir.remove(true);
   });
 
   // Select gzip request.
 
-  info("Open the context menu");
-
-  EventUtils.sendMouseEvent(
-    { type: "mousedown" },
-    document.querySelectorAll(".request-list-item")[6]
-  );
-
-  EventUtils.sendMouseEvent(
-    { type: "contextmenu" },
+  await triggerSaveResponseAs(
+    monitor,
     document.querySelectorAll(".request-list-item")[6]
   );
 
-  info("Open the save dialog");
-  await selectContextMenuItem(monitor, "request-list-context-save-response-as");
-
   info("Wait for the save dialog to close");
   const savedPath = await saveDialogClosedPromise;
 
@@ -69,14 +58,7 @@ add_task(async function () {
 
   is(savedPath, expectedFile.path, "Response was saved to correct path");
 
-  info("Wait for the downloaded file to be fully saved to disk: " + savedPath);
-  await TestUtils.waitForCondition(async () => {
-    if (!(await IOUtils.exists(savedPath))) {
-      return false;
-    }
-    const { size } = await IOUtils.stat(savedPath);
-    return size > 0;
-  });
+  await waitForFileSavedToDisk(savedPath);
 
   const buffer = await IOUtils.read(savedPath);
   const savedFileContent = new TextDecoder().decode(buffer);
@@ -91,12 +73,3 @@ add_task(async function () {
 
   await teardown(monitor);
 });
-
-function createTemporarySaveDirectory() {
-  const saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
-  saveDir.append("testsavedir");
-  if (!saveDir.exists()) {
-    saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
-  }
-  return saveDir;
-}
diff --git a/devtools/client/netmonitor/test/browser_net_save_response_as_sanitization.js b/devtools/client/netmonitor/test/browser_net_save_response_as_sanitization.js
new file mode 100644
index 00000000000..2e80f7f3549
--- /dev/null
+++ b/devtools/client/netmonitor/test/browser_net_save_response_as_sanitization.js
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window.browsingContext);
+
+/**
+ * Tests that filenames are sanitized when using Save Response As
+ */
+
+function setupTestServer() {
+  const httpServer = createTestHTTPServer();
+  httpServer.registerContentType("html", "text/html");
+
+  httpServer.registerPathHandler("/index.html", function (request, response) {
+    response.setStatusLine(request.httpVersion, 200, "OK");
+    response.write(`<!DOCTYPE html>
+    <html><body><h1>Test sanitization for save response as
+    <script>fetch("test.url");</script>
+    <script>fetch("test2.url");</script>
+    `);
+  });
+
+  httpServer.registerPathHandler("/test.url", function (request, response) {
+    response.setStatusLine(request.httpVersion, 200, "OK");
+    response.setHeader("Content-Type", "text/plain", false);
+    response.write("dummy content");
+  });
+  httpServer.registerPathHandler("/test2.url", function (request, response) {
+    response.setStatusLine(request.httpVersion, 200, "OK");
+    response.setHeader("Content-Type", "text/plain", false);
+    response.write("dummy content 2");
+  });
+
+  return httpServer;
+}
+
+add_task(async function () {
+  const httpServer = setupTestServer();
+  const port = httpServer.identity.primaryPort;
+
+  const { monitor } = await initNetMonitor(
+    `http://localhost:${port}/index.html`,
+    {
+      requestCount: 3,
+    }
+  );
+
+  info("Starting test... ");
+  const { document } = monitor.panelWin;
+
+  info("Reload the browser to show the 2 requests for the page");
+  const networkEvent = waitForNetworkEvents(monitor, 3);
+  await reloadBrowser();
+  await networkEvent;
+
+  // Create the folder the gzip file will be saved into
+  const destDir = createTemporarySaveDirectory();
+  let destFile;
+
+  // Prepare the MockFilePicker
+  MockFilePicker.displayDirectory = destDir;
+  registerCleanupFunction(function () {
+    MockFilePicker.cleanup();
+  });
+
+  info("Prepare a file picker mock which will use the default filename");
+  let saveDialogClosedPromise = new Promise(resolve => {
+    MockFilePicker.showCallback = function (fp) {
+      info("MockFilePicker showCallback - preserve .download extension");
+      const fileName = fp.defaultString;
+      destFile = destDir.clone();
+      destFile.append(fileName);
+      MockFilePicker.setFiles([destFile]);
+
+      resolve(destFile.path);
+    };
+  });
+
+  info("Save response as for the test.url request");
+  await triggerSaveResponseAs(
+    monitor,
+    document.querySelectorAll(".request-list-item")[1]
+  );
+
+  info("Wait for the save dialog to close");
+  const filePickerPath = await saveDialogClosedPromise;
+
+  const expectedFile = destDir.clone();
+  expectedFile.append("test.url.download");
+  is(
+    filePickerPath,
+    expectedFile.path,
+    "File picker default filename was set to the expected value"
+  );
+
+  await waitForFileSavedToDisk(expectedFile.path);
+
+  info("Prepare a file picker mock which will override the default filename");
+  saveDialogClosedPromise = new Promise(resolve => {
+    MockFilePicker.showCallback = function (fp) {
+      info("MockFilePicker showCallback - strip .download extension");
+      const fileName = fp.defaultString;
+      destFile = destDir.clone();
+      destFile.append(fileName.replace(".download", ""));
+      MockFilePicker.setFiles([destFile]);
+
+      resolve(destFile.path);
+    };
+  });
+
+  info("Save response as for test2.url");
+  await triggerSaveResponseAs(
+    monitor,
+    document.querySelectorAll(".request-list-item")[2]
+  );
+
+  info("Wait for the save dialog to close");
+  const updatedFilePickerPath = await saveDialogClosedPromise;
+
+  const invalidFile = destDir.clone();
+  invalidFile.append("test2.url");
+
+  const expectedFile2 = destDir.clone();
+  expectedFile2.append("test2.url.download");
+
+  is(
+    updatedFilePickerPath,
+    invalidFile.path,
+    "File picker filename was updated to an invalid path while saving"
+  );
+
+  // Check that the valid path was still used to save the file.
+  await waitForFileSavedToDisk(expectedFile2.path);
+
+  ok(
+    !(await IOUtils.exists(invalidFile.path)),
+    "No file was saved for the invalid path"
+  );
+
+  await teardown(monitor);
+});
diff --git a/devtools/client/netmonitor/test/head.js b/devtools/client/netmonitor/test/head.js
index d332cca066c..648000ffd6f 100644
--- a/devtools/client/netmonitor/test/head.js
+++ b/devtools/client/netmonitor/test/head.js
@@ -1788,3 +1788,60 @@ function findRequestByInitiator(document, initiator) {
   }
   return null;
 }
+
+/**
+ * Click on the "save response as" context menu item for the provided request
+ * element in the provided netmonitor panel.
+ *
+ * Resolves when the context menu is closed.
+ *
+ * @param {object} monitor
+ *     The netmonitor instance
+ * @param {HTMLElement} request
+ *     The request item in the netmonitor table
+ */
+async function triggerSaveResponseAs(monitor, request) {
+  EventUtils.sendMouseEvent({ type: "mousedown" }, request);
+  EventUtils.sendMouseEvent({ type: "contextmenu" }, request);
+
+  info("Open the save dialog");
+  await selectContextMenuItem(monitor, "request-list-context-save-response-as");
+}
+
+/**
+ * Wait until the provided path has a non-zero size on the file system.
+ *
+ * @param {string} path
+ *     The path to wait for.
+ */
+async function waitForFileSavedToDisk(path) {
+  info("Wait for the downloaded file to be fully saved to disk: " + path);
+  await TestUtils.waitForCondition(async () => {
+    if (!(await IOUtils.exists(path))) {
+      return false;
+    }
+    const { size } = await IOUtils.stat(path);
+    return size > 0;
+  });
+}
+
+/**
+ * Create a temporary directory to save files for a test.
+ * Register a cleanup function to delete the directory after the test.
+ *
+ * @returns {nsIFile}
+ *     The created temporary directory.
+ */
+function createTemporarySaveDirectory() {
+  const saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+  saveDir.append("testsavedir");
+
+  if (!saveDir.exists()) {
+    saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+  }
+
+  registerCleanupFunction(function () {
+    saveDir.remove(true);
+  });
+  return saveDir;
+}
Loading diff…