CVE-2024-9397
Overview
Background
- Clickjacking
- an attack in which a malicious page overlays or times UI so a user’s click lands on a security-sensitive control they did not intend to activate.
- Security delay
- a period during which a confirmation dialog’s accept button stays disabled so that a click cannot be registered immediately after the dialog appears.
- `BUTTON_DELAY_ENABLE`
- an
nsIPromptbutton flag that tells the prompt service to keep the associated button disabled for the duration ofsecurity.dialog_enable_delaybefore enabling it. - Directory upload prompt
- the tab-modal confirmation shown by
PromptCollectionwhen a web page requests to upload the contents of a directory.
Root Cause Analysis
When a web page triggered a directory (folder) upload, PromptCollection.confirmFolderUpload built a tab-modal confirmation prompt through Services.prompt and passed a buttonFlags value that did not include Ci.nsIPrompt.BUTTON_DELAY_ENABLE. Because that flag was absent, the “Upload” accept button was clickable the instant the dialog rendered, violating the invariant that a security-sensitive confirmation must not accept input until the user has had time to perceive it. An attacker could therefore position or time the dialog so a pre-placed or induced click was consumed by the accept button, granting directory upload permission without the user’s genuine intent.
The fix ORs Ci.nsIPrompt.BUTTON_DELAY_ENABLE into buttonFlags, so the accept button starts disabled and only becomes usable after security.dialog_enable_delay elapses, closing the click-timing window while leaving the default Cancel button immediately available.
BUTTON_DELAY_ENABLE flag when constructing the folder-upload confirmation, leaving its accept button live from the moment it appeared; the fix restores the standard security delay by setting that flag so the button cannot be clicked until the enforced delay passes.Attack Path
- Lure the victim
The attacker gets a user to visit a page that can invoke a directory upload (for example via a
webkitdirectoryfile input or the File System Access API). - Trigger the prompt
Script on the page programmatically initiates the folder upload, causing
PromptCollection.confirmFolderUploadto display the tab-modal “Upload” confirmation. - Overlay or time the click
Because the accept button lacks
BUTTON_DELAY_ENABLE, the attacker uses a decoy UI or a scripted/coerced click that lands on the “Upload” button the instant the dialog appears. - Consume the permission The immediate click activates the accept button, so the browser grants the directory upload before the user recognizes what was confirmed.
- Exfiltrate directory contents With permission granted, the page reads and uploads the contents of the selected directory to attacker-controlled storage.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifdom/filesystem/tests/script_promptHandler.js |
modified | |
fordom/filesystem/tests/script_promptHandler.js |
modified |
Files Changed
browser/base/content/test/tabPrompts/browser_confirmFolderUpload.jsbrowser/components/prompts/PromptCollection.sys.mjsdom/filesystem/tests/script_promptHandler.js
Audit Directions
- Missing `BUTTON_DELAY_ENABLE` on sensitive promptsSearch other
Services.prompt/nsIPromptcall sites that grant permissions or perform destructive actions and confirm each security-relevant accept button sets the delay flag. - Immediately actionable confirmation dialogsReview UI that appears in response to web-content-triggered events for any accept control that is clickable before the user can react, which invites clickjacking.
- Consistency across permission surfacesCompare directory/file upload, download, and File System Access confirmations to ensure the same delay and default-Cancel protections are applied uniformly.
Patch
diff --git a/browser/components/prompts/PromptCollection.sys.mjs b/browser/components/prompts/PromptCollection.sys.mjs
index 85a7a10057a..ebb630fe152 100644
--- a/browser/components/prompts/PromptCollection.sys.mjs
+++ b/browser/components/prompts/PromptCollection.sys.mjs
@@ -195,7 +195,7 @@ export class PromptCollection {
Services.prompt.MODAL_TYPE_TAB,
title,
message,
- buttonFlags,
+ buttonFlags | Ci.nsIPrompt.BUTTON_DELAY_ENABLE,
acceptLabel,
null,
null,
diff --git a/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js b/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js
index a7e5d76fc40..d62edfd7ebb 100644
--- a/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js
+++ b/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js
@@ -107,8 +107,29 @@ async function testUploadPrompt(confirmUpload) {
// Wait for confirmation prompt
let prompt = await promptPromise;
ok(prompt, "Shown upload confirmation prompt");
+
is(prompt.ui.button0.label, "Upload", "Accept button label");
+ ok(
+ prompt.ui.button0.disabled,
+ "Accept button should be disabled by the security delay initially."
+ );
+
ok(prompt.ui.button1.hasAttribute("default"), "Cancel is default button");
+ ok(
+ !prompt.ui.button1.disabled,
+ "Cancel button should not be disabled by the security delay."
+ );
+
+ info("Wait for the security delay to pass.");
+ let delayTime = Services.prefs.getIntPref("security.dialog_enable_delay");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, delayTime + 100));
+
+ ok(
+ !prompt.ui.button0.disabled,
+ "Accept button should no longer be disabled."
+ );
+ ok(!prompt.ui.button1.disabled, "Cancel button should remain enabled.");
// Close confirmation prompt
await PromptTestUtils.handlePrompt(prompt, {
diff --git a/dom/filesystem/tests/script_promptHandler.js b/dom/filesystem/tests/script_promptHandler.js
index 235842b455d..9b412a85bb3 100644
--- a/dom/filesystem/tests/script_promptHandler.js
+++ b/dom/filesystem/tests/script_promptHandler.js
@@ -2,7 +2,45 @@
let dialogObserverTopic = "common-dialog-loaded";
-function dialogObserver(subj) {
+function waitForButtonEnabledState(button) {
+ return new Promise(resolve => {
+ // Check if the button is already enabled (not disabled)
+ if (!button.disabled) {
+ resolve();
+ return;
+ }
+
+ // Create a MutationObserver instance
+ let win = button.ownerGlobal;
+ let { MutationObserver } = win;
+ const observer = new MutationObserver(mutationsList => {
+ for (const mutation of mutationsList) {
+ if (
+ mutation.type === "attributes" &&
+ mutation.attributeName === "disabled"
+ ) {
+ if (!button.disabled) {
+ // Resolve the promise when the button is enabled
+ observer.disconnect(); // Stop observing
+ resolve();
+ }
+ }
+ }
+ });
+
+ // Start observing the button for changes to the 'disabled' attribute
+ observer.observe(button, {
+ attributes: true,
+ attributeFilter: ["disabled"],
+ });
+ });
+}
+
+async function dialogObserver(subj) {
+ let dialog = subj.document.querySelector("dialog");
+ let acceptButton = dialog.getButton("accept");
+ await waitForButtonEnabledState(acceptButton);
+
subj.document.querySelector("dialog").acceptDialog();
sendAsyncMessage("promptAccepted");
}
Regression Test / PoC
diff --git a/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js b/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js
index a7e5d76fc40..d62edfd7ebb 100644
--- a/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js
+++ b/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js
@@ -107,8 +107,29 @@ async function testUploadPrompt(confirmUpload) {
// Wait for confirmation prompt
let prompt = await promptPromise;
ok(prompt, "Shown upload confirmation prompt");
+
is(prompt.ui.button0.label, "Upload", "Accept button label");
+ ok(
+ prompt.ui.button0.disabled,
+ "Accept button should be disabled by the security delay initially."
+ );
+
ok(prompt.ui.button1.hasAttribute("default"), "Cancel is default button");
+ ok(
+ !prompt.ui.button1.disabled,
+ "Cancel button should not be disabled by the security delay."
+ );
+
+ info("Wait for the security delay to pass.");
+ let delayTime = Services.prefs.getIntPref("security.dialog_enable_delay");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, delayTime + 100));
+
+ ok(
+ !prompt.ui.button0.disabled,
+ "Accept button should no longer be disabled."
+ );
+ ok(!prompt.ui.button1.disabled, "Cancel button should remain enabled.");
// Close confirmation prompt
await PromptTestUtils.handlePrompt(prompt, {
diff --git a/dom/filesystem/tests/script_promptHandler.js b/dom/filesystem/tests/script_promptHandler.js
index 235842b455d..9b412a85bb3 100644
--- a/dom/filesystem/tests/script_promptHandler.js
+++ b/dom/filesystem/tests/script_promptHandler.js
@@ -2,7 +2,45 @@
let dialogObserverTopic = "common-dialog-loaded";
-function dialogObserver(subj) {
+function waitForButtonEnabledState(button) {
+ return new Promise(resolve => {
+ // Check if the button is already enabled (not disabled)
+ if (!button.disabled) {
+ resolve();
+ return;
+ }
+
+ // Create a MutationObserver instance
+ let win = button.ownerGlobal;
+ let { MutationObserver } = win;
+ const observer = new MutationObserver(mutationsList => {
+ for (const mutation of mutationsList) {
+ if (
+ mutation.type === "attributes" &&
+ mutation.attributeName === "disabled"
+ ) {
+ if (!button.disabled) {
+ // Resolve the promise when the button is enabled
+ observer.disconnect(); // Stop observing
+ resolve();
+ }
+ }
+ }
+ });
+
+ // Start observing the button for changes to the 'disabled' attribute
+ observer.observe(button, {
+ attributes: true,
+ attributeFilter: ["disabled"],
+ });
+ });
+}
+
+async function dialogObserver(subj) {
+ let dialog = subj.document.querySelector("dialog");
+ let acceptButton = dialog.getButton("accept");
+ await waitForButtonEnabledState(acceptButton);
+
subj.document.querySelector("dialog").acceptDialog();
sendAsyncMessage("promptAccepted");
}