Firefox · DOM
CVE-2026-74971
Logic Error in DOM
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
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 |
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
Patch
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…
References
On This Page