Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionInformation disclosure in the DOM: UI Events & Focus Handling component
ComponentDOM
Bug ClassLogic Error
Tracker2057204
Fix commit7159d5f975aa (firefox) +247/-2
CISA KEVNot listed
Creditedavlidienbrunn
Disclosed2026-08-18

Changed Functions

FunctionChangeNotes
add_task
dom/html/test/browser_input_file_untrusted_drop.js
modified
if
dom/html/test/browser_input_file_untrusted_drop.js
modified

Files Changed

  • dom/html/test/browser.toml
  • dom/html/test/browser_input_file_untrusted_drop.js
  • dom/html/test/file_input_file_untrusted_drop.html
  • layout/forms/nsFileControlFrame.cpp
diff --git a/dom/html/test/browser.toml b/dom/html/test/browser.toml
index 6ae6d9f5c4c..46c6ccaef38 100644
--- a/dom/html/test/browser.toml
+++ b/dom/html/test/browser.toml
@@ -41,6 +41,9 @@ support-files = [
 
 ["browser_input_file_picker_opening_notification.js"]
 
+["browser_input_file_untrusted_drop.js"]
+support-files = ["file_input_file_untrusted_drop.html"]
+
 ["browser_refresh_after_document_write.js"]
 support-files = ["file_refresh_after_document_write.html"]
 
diff --git a/dom/html/test/browser_input_file_untrusted_drop.js b/dom/html/test/browser_input_file_untrusted_drop.js
new file mode 100644
index 00000000000..46f64eefa48
--- /dev/null
+++ b/dom/html/test/browser_input_file_untrusted_drop.js
@@ -0,0 +1,164 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+  "chrome://mochitests/content",
+  "https://example.com"
+);
+const PAGE = TEST_PATH + "file_input_file_untrusted_drop.html";
+
+const FILE_CONTENTS = "not for the page to see";
+
+async function createDraggedFile() {
+  const path = PathUtils.join(
+    PathUtils.tempDir,
+    "browser_input_file_untrusted_drop.txt"
+  );
+  await IOUtils.writeUTF8(path, FILE_CONTENTS);
+  registerCleanupFunction(() => IOUtils.remove(path, { ignoreAbsent: true }));
+  return File.createFromFileName(path);
+}
+
+function dragFileOverInput(browser, file) {
+  return SpecialPowers.spawn(browser, [file], async draggedFile => {
+    const doc = content.document;
+    const input = doc.getElementById("fileinput");
+
+    content.wrappedJSObject.installDragEnterStealer();
+
+    const dragData = [[{ type: "application/x-moz-file", data: draggedFile }]];
+
+    EventUtils.startDragSession(content, "copy");
+    try {
+      // Fires dragstart on #dragsource, then dragenter and dragover on the
+      // file input. No drop.
+      EventUtils.synthesizeDragOver(
+        doc.getElementById("dragsource"),
+        input,
+        dragData,
+        "copy",
+        content
+      );
+    } finally {
+      content.windowUtils.dragSession?.endDragSession(true);
+    }
+
+    const stolen = content.wrappedJSObject.stealResult;
+    return {
+      fileCountAfterDrag: input.files.length,
+      stolen: stolen
+        ? {
+            types: Array.from(stolen.types),
+            itemCount: stolen.itemCount,
+            fileCountDuringDragEnter: stolen.fileCountDuringDragEnter,
+            anyItemReadableAsFile: stolen.anyItemReadableAsFile,
+            dropEventCancelled: stolen.dropEventCancelled,
+            fileCountAfterUntrustedDrop: stolen.fileCountAfterUntrustedDrop,
+          }
+        : null,
+    };
+  });
+}
+
+add_task(async function test_stolen_datatransfer_in_untrusted_drop() {
+  const file = await createDraggedFile();
+
+  await BrowserTestUtils.withNewTab(PAGE, async browser => {
+    const result = await dragFileOverInput(browser, file);
+
+    if (!ok(result.stolen, "The page saw a dragenter event")) {
+      return;
+    }
+
+    ok(
+      result.stolen.types.includes("Files"),
+      "The page can see that the drag carries a file"
+    );
+    is(result.stolen.itemCount, 1, "The page can see one item");
+    is(
+      result.stolen.fileCountDuringDragEnter,
+      0,
+      "DataTransfer.files is empty during dragenter"
+    );
+    ok(
+      !result.stolen.anyItemReadableAsFile,
+      "getAsFile() returns null during dragenter"
+    );
+
+    ok(
+      !result.stolen.dropEventCancelled,
+      "The untrusted drop event was not handled by the file control"
+    );
+    is(
+      result.stolen.fileCountAfterUntrustedDrop,
+      0,
+      "The untrusted drop did not set input.files"
+    );
+    is(result.fileCountAfterDrag, 0, "input.files is still empty");
+  });
+});
+
+add_task(async function test_untrusted_events_with_page_made_datatransfer() {
+  await BrowserTestUtils.withNewTab(PAGE, async browser => {
+    const [dragOverAllowed, fileCount] = await SpecialPowers.spawn(
+      browser,
+      [],
+      () => [
+        content.wrappedJSObject.spoofUntrustedDragOver(),
+        content.wrappedJSObject.spoofUntrustedDrop(),
+      ]
+    );
+
+    ok(
+      dragOverAllowed,
+      "The file control did not preventDefault() an untrusted dragover"
+    );
+    is(fileCount, 0, "An untrusted drop did not set input.files");
+  });
+});
+
+// Make sure the above isn't passing because dropping files stopped working.
+add_task(async function test_trusted_drop_still_works() {
+  const file = await createDraggedFile();
+
+  await BrowserTestUtils.withNewTab(PAGE, async browser => {
+    const result = await SpecialPowers.spawn(
+      browser,
+      [file],
+      async draggedFile => {
+        const doc = content.document;
+        const input = doc.getElementById("fileinput");
+
+        const changed = new Promise(resolve =>
+          input.addEventListener("change", resolve, { once: true })
+        );
+
+        EventUtils.synthesizeDrop(
+          doc.getElementById("dragsource"),
+          input,
+          [[{ type: "application/x-moz-file", data: draggedFile }]],
+          "copy",
+          content
+        );
+
+        // Only wait for the change event if the drop actually did something,
+        // otherwise a regression here would hang until the test times out
+        // instead of failing with a useful message.
+        const fileCount = input.files.length;
+        if (fileCount) {
+          await changed;
+        }
+
+        return {
+          fileCount,
+          text: fileCount ? await input.files[0].text() : null,
+        };
+      }
+    );
+
+    is(result.fileCount, 1, "A trusted drop set input.files");
+    is(result.text, FILE_CONTENTS, "The dropped file is readable");
+  });
+});
diff --git a/dom/html/test/file_input_file_untrusted_drop.html b/dom/html/test/file_input_file_untrusted_drop.html
new file mode 100644
index 00000000000..0b61b9d2472
--- /dev/null
+++ b/dom/html/test/file_input_file_untrusted_drop.html
@@ -0,0 +1,77 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Untrusted drop events on input type=file</title>
+</head>
+<body>
+<div id="dragsource" draggable="true" style="width: 100px; height: 50px">drag me</div>
+<input id="fileinput" type="file">
+<script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/dom/html/test/browser.toml b/dom/html/test/browser.toml
index 6ae6d9f5c4c..46c6ccaef38 100644
--- a/dom/html/test/browser.toml
+++ b/dom/html/test/browser.toml
@@ -41,6 +41,9 @@ support-files = [
 
 ["browser_input_file_picker_opening_notification.js"]
 
+["browser_input_file_untrusted_drop.js"]
+support-files = ["file_input_file_untrusted_drop.html"]
+
 ["browser_refresh_after_document_write.js"]
 support-files = ["file_refresh_after_document_write.html"]
diff --git a/dom/html/test/browser_input_file_untrusted_drop.js b/dom/html/test/browser_input_file_untrusted_drop.js
new file mode 100644
index 00000000000..46f64eefa48
--- /dev/null
+++ b/dom/html/test/browser_input_file_untrusted_drop.js
@@ -0,0 +1,164 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+  "chrome://mochitests/content",
+  "https://example.com"
+);
+const PAGE = TEST_PATH + "file_input_file_untrusted_drop.html";
+
+const FILE_CONTENTS = "not for the page to see";
+
+async function createDraggedFile() {
+  const path = PathUtils.join(
+    PathUtils.tempDir,
+    "browser_input_file_untrusted_drop.txt"
+  );
+  await IOUtils.writeUTF8(path, FILE_CONTENTS);
+  registerCleanupFunction(() => IOUtils.remove(path, { ignoreAbsent: true }));
+  return File.createFromFileName(path);
+}
+
+function dragFileOverInput(browser, file) {
+  return SpecialPowers.spawn(browser, [file], async draggedFile => {
+    const doc = content.document;
+    const input = doc.getElementById("fileinput");
+
+    content.wrappedJSObject.installDragEnterStealer();
+
+    const dragData = [[{ type: "application/x-moz-file", data: draggedFile }]];
+
+    EventUtils.startDragSession(content, "copy");
+    try {
+      // Fires dragstart on #dragsource, then dragenter and dragover on the
+      // file input. No drop.
+      EventUtils.synthesizeDragOver(
+        doc.getElementById("dragsource"),
+        input,
+        dragData,
+        "copy",
+        content
+      );
+    } finally {
+      content.windowUtils.dragSession?.endDragSession(true);
+    }
+
+    const stolen = content.wrappedJSObject.stealResult;
+    return {
+      fileCountAfterDrag: input.files.length,
+      stolen: stolen
+        ? {
+            types: Array.from(stolen.types),
+            itemCount: stolen.itemCount,
+            fileCountDuringDragEnter: stolen.fileCountDuringDragEnter,
+            anyItemReadableAsFile: stolen.anyItemReadableAsFile,
+            dropEventCancelled: stolen.dropEventCancelled,
+            fileCountAfterUntrustedDrop: stolen.fileCountAfterUntrustedDrop,
+          }
+        : null,
+    };
+  });
+}
+
+add_task(async function test_stolen_datatransfer_in_untrusted_drop() {
+  const file = await createDraggedFile();
+
+  await BrowserTestUtils.withNewTab(PAGE, async browser => {
+    const result = await dragFileOverInput(browser, file);
+
+    if (!ok(result.stolen, "The page saw a dragenter event")) {
+      return;
+    }
+
+    ok(
+      result.stolen.types.includes("Files"),
+      "The page can see that the drag carries a file"
+    );
+    is(result.stolen.itemCount, 1, "The page can see one item");
+    is(
+      result.stolen.fileCountDuringDragEnter,
+      0,
+      "DataTransfer.files is empty during dragenter"
+    );
+    ok(
+      !result.stolen.anyItemReadableAsFile,
+      "getAsFile() returns null during dragenter"
+    );
+
+    ok(
+      !result.stolen.dropEventCancelled,
+      "The untrusted drop event was not handled by the file control"
+    );
+    is(
+      result.stolen.fileCountAfterUntrustedDrop,
+      0,
+      "The untrusted drop did not set input.files"
+    );
+    is(result.fileCountAfterDrag, 0, "input.files is still empty");
+  });
+});
+
+add_task(async function test_untrusted_events_with_page_made_datatransfer() {
+  await BrowserTestUtils.withNewTab(PAGE, async browser => {
+    const [dragOverAllowed, fileCount] = await SpecialPowers.spawn(
+      browser,
+      [],
+      () => [
+        content.wrappedJSObject.spoofUntrustedDragOver(),
+        content.wrappedJSObject.spoofUntrustedDrop(),
+      ]
+    );
+
+    ok(
+      dragOverAllowed,
+      "The file control did not preventDefault() an untrusted dragover"
+    );
+    is(fileCount, 0, "An untrusted drop did not set input.files");
+  });
+});
+
+// Make sure the above isn't passing because dropping files stopped working.
+add_task(async function test_trusted_drop_still_works() {
+  const file = await createDraggedFile();
+
+  await BrowserTestUtils.withNewTab(PAGE, async browser => {
+    const result = await SpecialPowers.spawn(
+      browser,
+      [file],
+      async draggedFile => {
+        const doc = content.document;
+        const input = doc.getElementById("fileinput");
+
+        const changed = new Promise(resolve =>
+          input.addEventListener("change", resolve, { once: true })
+        );
+
+        EventUtils.synthesizeDrop(
+          doc.getElementById("dragsource"),
+          input,
+          [[{ type: "application/x-moz-file", data: draggedFile }]],
+          "copy",
+          content
+        );
+
+        // Only wait for the change event if the drop actually did something,
+        // otherwise a regression here would hang until the test times out
+        // instead of failing with a useful message.
+        const fileCount = input.files.length;
+        if (fileCount) {
+          await changed;
+        }
+
+        return {
+          fileCount,
+          text: fileCount ? await input.files[0].text() : null,
+        };
+      }
+    );
+
+    is(result.fileCount, 1, "A trusted drop set input.files");
+    is(result.text, FILE_CONTENTS, "The dropped file is readable");
+  });
+});
diff --git a/dom/html/test/file_input_file_untrusted_drop.html b/dom/html/test/file_input_file_untrusted_drop.html
new file mode 100644
index 00000000000..0b61b9d2472
--- /dev/null
+++ b/dom/html/test/file_input_file_untrusted_drop.html
@@ -0,0 +1,77 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Untrusted drop events on input type=file</title>
+</head>
+<body>
+<div id="dragsource" draggable="true" style="width: 100px; height: 50px">drag me</div>
+<input id="fileinput" type="file">
+<script>
+var stealResult = null;
+
+function installDragEnterStealer() {
+  window.addEventListener("dragenter", onDragEnter, true);
+}
+
+function onDragEnter(event) {
+  const dt = event.dataTransfer;
+  const input = document.getElementById("fileinput");
+
+  let anyItemReadableAsFile = false;
+  for (const item of dt.items) {
+    if (item.getAsFile()) {
+      anyItemReadableAsFile = true;
+    }
+  }
+
+  stealResult = {
+    types: Array.from(dt.types),
+    itemCount: dt.items.length,
+    fileCountDuringDragEnter: dt.files.length,
+    anyItemReadableAsFile,
+  };
+
+  stealResult.dropEventCancelled = !input.dispatchEvent(
+    new DragEvent("drop", {
+      bubbles: true,
+      cancelable: true,
+      dataTransfer: dt,
+    })
+  );
+  stealResult.fileCountAfterUntrustedDrop = input.files.length;
+}
+
+function spoofUntrustedDrop() {
+  const input = document.getElementById("fileinput");
+  input.dispatchEvent(
+    new DragEvent("drop", {
+      bubbles: true,
+      cancelable: true,
+      dataTransfer: makeFileDataTransfer(),
+    })
+  );
+  return input.files.length;
+}
+
+function spoofUntrustedDragOver() {
+  const input = document.getElementById("fileinput");
+  // Returns false if something called preventDefault(), which is how the file
+  // control signals that it would accept the drag.
+  return input.dispatchEvent(
+    new DragEvent("dragover", {
+      bubbles: true,
+      cancelable: true,
+      dataTransfer: makeFileDataTransfer(),
+    })
+  );
+}
+
+function makeFileDataTransfer() {
+  const dt = new DataTransfer();
+  dt.items.add(new File(["spoofed"], "spoofed.txt", { type: "text/plain" }));
+  return dt;
+}
+</script>
+</body>
+</html>
Loading diff…