Medium firefox Info Disclosure 🔧 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 ClassInfo Disclosure
Tracker2057204
Fix commit7159d5f975aa (firefox) +247/-2
CISA KEVNot listed
Creditedavlidienbrunn
Disclosed2026-08-18

Background

`nsFileControlFrame`
the layout frame that renders and manages the anonymous content and drag-and-drop behavior of an <input type="file"> element.
`AddSystemEventListener`
a Gecko EventTarget API that registers a listener in the system event group, whose trailing aWantsUntrusted argument controls whether the listener also fires for script-synthesized (untrusted) events.
Trusted vs. untrusted events
a trusted event originates from a genuine user action or the browser engine, whereas an untrusted event is one a web page created and dispatched via script (e.g. new DragEvent(...) + dispatchEvent).
`DataTransfer`
the object carried by drag events; during a real OS-initiated drag it may reference files on disk that the page must not read until the user actually drops them on a legitimate target.

Root Cause Analysis

In nsFileControlFrame::CreateAnonymousContent, the file control registered its mMouseListener for the "drop" and "dragover" events by calling AddSystemEventListener without supplying the aWantsUntrusted argument. For a content node, the omitted default for aWantsUntrusted resolves to true, so the control’s drop/dragover handlers fired not only for genuine user drags but also for events a web page synthesized in script. This breaks the invariant that a file input should only accept and act on drop/dragover gestures the user actually performed. An attacker page could install a capturing dragenter listener during a real drag, capture the live DataTransfer (which references the user’s real file), and re-dispatch it as an untrusted "drop" event at the input, causing the control to ingest and populate input.files with a file the user never intentionally dropped.

The fix passes false for aWantsUntrusted on both listeners, so the file control ignores page-synthesized "drop" and "dragover" events and only responds to trusted, user-driven drags.

Key insight
The single mistake was registering the file control’s drag-and-drop listeners without pinning aWantsUntrusted to false, letting a content-node default of true expose them to script-dispatched events; the fix explicitly passes false so only trusted user gestures reach mMouseListener.

Attack Path

  1. Bait the drag The attacker page lures the user into starting a drag of a local file over the page (e.g. an image or document the user drags onto a decoy area).
  2. Steal the live DataTransfer A capturing "dragenter" listener grabs the trusted event’s DataTransfer, which still references the real file even though getAsFile() and .files are gated during dragenter.
  3. Synthesize an untrusted drop The page dispatches a script-made "drop" DragEvent at the <input type="file">, reusing the stolen DataTransfer.
  4. File control ingests it Because the listener defaulted to aWantsUntrusted = true, mMouseListener handles the untrusted drop and sets input.files to the user’s real file.
  5. Read the contents The page reads the now-populated input.files[0] (e.g. via File.text()), disclosing file contents the user never chose to share.

Impact Assessment

An attacker-controlled web page running in the content process gains the ability to coax the file control into accepting a file the user was merely dragging (not dropping onto the input), then read that file’s contents, resulting in local file information disclosure. Exploitation requires the user to initiate a real drag of a sensitive file over the malicious page, since a purely page-created DataTransfer yields nothing the page did not already have. The severity is moderate because it depends on user interaction and a plausibly-crafted drag target rather than being fully automatic.

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
for
dom/html/test/file_input_file_untrusted_drop.html
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

Audit Directions

  • Untrusted-event default on `AddSystemEventListener`
    Review every C++ call to AddSystemEventListener/AddEventListener on content nodes where aWantsUntrusted is omitted; the content-node default is true, so security-sensitive handlers may be reachable by script-dispatched events.
  • Trust checks in drag/drop handlers
    Audit mMouseListener and similar drop handlers to confirm they verify event.isTrusted (or are only registered for trusted events) before mutating state such as input.files.
  • Live `DataTransfer` reuse
    Look for handlers that act on a DataTransfer obtained from one event when dispatched through a different, untrusted event, since the file-reference gating during dragenter/dragover can be bypassed by re-dispatching a captured transfer.
diff --git a/layout/forms/nsFileControlFrame.cpp b/layout/forms/nsFileControlFrame.cpp
index 51859d9a8c8..06025db112b 100644
--- a/layout/forms/nsFileControlFrame.cpp
+++ b/layout/forms/nsFileControlFrame.cpp
@@ -138,8 +138,9 @@ nsresult nsFileControlFrame::CreateAnonymousContent(
   aElements.AppendElement(mTextContent);
 
   // We should be able to interact with the element by doing drag and drop.
-  mContent->AddSystemEventListener(u"drop"_ns, mMouseListener, false);
-  mContent->AddSystemEventListener(u"dragover"_ns, mMouseListener, false);
+  mContent->AddSystemEventListener(u"drop"_ns, mMouseListener, false, false);
+  mContent->AddSystemEventListener(u"dragover"_ns, mMouseListener, false,
+                                   false);
 
   SyncDisabledState();
 
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…

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…