Low firefox Logic Error 🔧 Commit mapped

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionClickjacking issue in the Downloads component in Firefox for Android
ComponentCore
Bug ClassLogic Error
Tracker2049034
Fix commit5bf9b39dde43 (firefox) +40/-6
CISA KEVNot listed
CreditedHafiizh
Disclosed2026-08-18

Background

`RenameAndChangeLocationDialogFragment`
a Fenix (Firefox for Android) DialogFragment that lets the user rename a download and choose its save directory before confirming.
`PromptAbuserDetector`
a Mozilla Android components helper that flags dialog abuse by tracking how quickly and how often a prompt is confirmed, exposing areDialogsBeingAbused().
Clickjacking
an attack in which a security-relevant UI element is placed or timed so a victim confirms an action they did not intend.
`OnEnterAnimationCompleteListener`
an interface whose onEnterAnimationComplete() fires once a fragment’s enter animation finishes, marking the earliest point the dialog is truly visible.

Root Cause Analysis

The onConfirm handler in RenameAndChangeLocationDialogFragment invoked onConfirmSave(...) and dismiss() immediately, with no guard against a confirmation that arrives before the user could reasonably have seen the dialog. Because the dialog carried no PromptAbuserDetector, the invariant that a destructive/consequential prompt must be visible for a minimum interval before its confirm action can fire was never enforced. An attacker could therefore surface the rename/change-location dialog and drive an instant confirm (a timed or overlaid tap), committing the save without genuine user intent.

The fix instantiates a PromptAbuserDetector(TIME_SHOWN_OFFSET_MILLIS), starts its timer at each point the dialog becomes visible (onResume, onEnterAnimationComplete, and dialog creation), and gates onConfirm so that if areDialogsBeingAbused() returns true the confirmation is rejected via updateJSDialogAbusedState() instead of saving. This works because a confirm that lands within the 500 ms TIME_SHOWN_OFFSET_MILLIS window is treated as abuse and discarded, forcing a legitimate, human-timed interaction.

Key insight
The single core mistake was that this download dialog’s confirm path had no time/rate abuse check while comparable prompts do, so an instantaneous confirm was accepted as genuine. The fix wires PromptAbuserDetector into the visibility lifecycle and makes onConfirm refuse confirmations that occur too fast to be intentional.

Attack Path

  1. Lure the victim A malicious page induces the user to start a download, causing the rename/change-location dialog to appear.
  2. Exploit the timing gap Because no minimum-visibility check existed, the confirm button was actionable the instant the dialog rendered.
  3. Drive an unintended confirm A timed or overlaid tap triggers onConfirm, which called onConfirmSave(...) and dismiss() with no guard.
  4. Silent commit The download is saved to the attacker-influenced name/location without the user consciously agreeing.

Impact Assessment

An attacker gains the ability to have a download rename/save-location confirmation accepted without deliberate user consent, within the Fenix (Firefox for Android) UI/application process. The precondition is that the attacker can get the download dialog on screen and cause a confirming tap to land faster than a real user could react. Impact is low: it manipulates a local download-save decision rather than yielding memory corruption or code execution.

Changed Functions

FunctionChangeNotes
Transition
mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt
modified
RenameAndChangeLocationDialogFragment
mobile/android/fenix/app/src/main/java/org/mozilla/fenix/downloads/RenameAndChangeLocationDialogFragment.kt
modified

Files Changed

  • mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt
  • mobile/android/fenix/app/src/main/java/org/mozilla/fenix/downloads/RenameAndChangeLocationDialogFragment.kt

Audit Directions

  • Missing abuse guard on consequential dialogs
    Audit every Fenix DialogFragment whose confirm action performs a filesystem, permission, or download decision to ensure it wires in PromptAbuserDetector like this dialog now does.
  • Timer anchored to visibility
    Verify that any prompt-timing check starts on real visibility lifecycle points (onResume, onEnterAnimationComplete, dialog creation) rather than construction, so overlay/animation races cannot shrink the protection window.
  • Confirm-path bypass
    Review confirm handlers to confirm the abuse branch actually blocks the sensitive action (returns/updateJSDialogAbusedState()) instead of merely logging while still calling the save/commit callback.
diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt
index e3dc093d419..bde1643a9a4 100644
--- a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt
+++ b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt
@@ -29,6 +29,7 @@ import androidx.test.espresso.intent.matcher.IntentMatchers
 import androidx.test.uiautomator.By
 import androidx.test.uiautomator.UiSelector
 import androidx.test.uiautomator.Until
+import mozilla.components.support.ktx.util.PromptAbuserDetector
 import org.hamcrest.CoreMatchers.allOf
 import org.mozilla.fenix.R
 import org.mozilla.fenix.compose.snackbar.SNACKBAR_TEST_TAG
@@ -257,7 +258,9 @@ class DownloadRobot(private val composeTestRule: ComposeTestRule) {
     class Transition(private val composeTestRule: ComposeTestRule) {
         fun clickDownload(composeTestRule: ComposeTestRule, interact: DownloadRobot.() -> Unit): Transition {
             Log.i(TAG, "clickDownload: Trying to click the \"Download\" download prompt button")
+            PromptAbuserDetector.validationsEnabled = false
             composeTestRule.downloadButton().performClick()
+            PromptAbuserDetector.validationsEnabled = true
             Log.i(TAG, "clickDownload: Clicked the \"Download\" download prompt button")
 
             DownloadRobot(composeTestRule).interact()
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/downloads/RenameAndChangeLocationDialogFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/downloads/RenameAndChangeLocationDialogFragment.kt
index 61af9284515..0f7a30b76e5 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/downloads/RenameAndChangeLocationDialogFragment.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/downloads/RenameAndChangeLocationDialogFragment.kt
@@ -20,6 +20,8 @@ import androidx.fragment.app.DialogFragment
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import mozilla.components.concept.base.crash.Breadcrumb
 import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.ktx.util.PromptAbuserDetector
+import mozilla.components.support.utils.OnEnterAnimationCompleteListener
 import org.mozilla.fenix.R
 import org.mozilla.fenix.ext.components
 import org.mozilla.fenix.ext.requireComponents
@@ -38,10 +40,12 @@ import org.mozilla.fenix.theme.FirefoxTheme
  *
  * The callback [onConfirmSave] is invoked with the final file name and directory path.
  */
-class RenameAndChangeLocationDialogFragment : DialogFragment() {
+class RenameAndChangeLocationDialogFragment : DialogFragment(), OnEnterAnimationCompleteListener {
     private val logger = Logger("RenameAndChangeLocationDialogFragment")
     private val safeArguments get() = requireNotNull(arguments)
 
+    private val promptAbuserDetector = PromptAbuserDetector(TIME_SHOWN_OFFSET_MILLIS)
+
     internal val fileName: String
         get() = safeArguments.getString(KEY_FILE_NAME, "")
 
@@ -75,6 +79,15 @@ class RenameAndChangeLocationDialogFragment : DialogFragment() {
         }
     }
 
+    override fun onResume() {
+        super.onResume()
+        promptAbuserDetector.start()
+    }
+
+    override fun onEnterAnimationComplete() {
+        promptAbuserDetector.start()
+    }
+
     override fun onCancel(dialog: DialogInterface) {
         super.onCancel(dialog)
         onCancel()
@@ -99,6 +112,8 @@ class RenameAndChangeLocationDialogFragment : DialogFragment() {
 
         val composeView = createComposeView()
 
+        promptAbuserDetector.start()
+
         return MaterialAlertDialogBuilder(requireContext())
             .setView(composeView)
             .create()
@@ -144,11 +159,15 @@ class RenameAndChangeLocationDialogFragment : DialogFragment() {
                             directoryLauncher.launch(null)
                         },
                         onConfirm = {
-                            onConfirmSave(
-                                dialogState.fileName,
-                                dialogState.directoryPath,
-                            )
-                            dismiss()
+                            if (promptAbuserDetector.areDialogsBeingAbused()) {
+                                promptAbuserDetector.updateJSDialogAbusedState()
+                            } else {
+                                onConfirmSave(
+                                    dialogState.fileName,
+                                    dialogState.directoryPath,
+                                )
+                                dismiss()
+                            }
                         },
                         onCancel = {
                             onCancel()
@@ -182,6 +201,7 @@ class RenameAndChangeLocationDialogFragment : DialogFragment() {
         private const val KEY_DIRECTORY_PATH = "directory_path"
         private const val KEY_CONTENT_SIZE = "content_size"
         const val RENAME_AND_CHANGE_LOCATION_DIALOG_TAG = "RENAME_AND_CHANGE_LOCATION_DIALOG_TAG"
+        private const val TIME_SHOWN_OFFSET_MILLIS = 500
 
         /**
          * Creates a new instance of [RenameAndChangeLocationDialogFragment].
@@ -203,3 +223,14 @@ class RenameAndChangeLocationDialogFragment : DialogFragment() {
         }
     }
 }
+
+/**
+ * Starts (or restarts) the time-based check without increasing the "click count".
+ *
+ * Makes it safe to call from multiple/successive lifecycle methods, without running into the risk
+ * of triggering the more restrictive count-based protection on the 1st click (or even before it).
+ */
+private fun PromptAbuserDetector.start() {
+    resetJSAlertAbuseState()
+    updateJSDialogAbusedState()
+}
Loading diff…