CVE-2026-74980
Overview
Background
- `RenameAndChangeLocationDialogFragment`
- a Fenix (Firefox for Android)
DialogFragmentthat 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.
PromptAbuserDetector into the visibility lifecycle and makes onConfirm refuse confirmations that occur too fast to be intentional.Attack Path
- Lure the victim A malicious page induces the user to start a download, causing the rename/change-location dialog to appear.
- Exploit the timing gap Because no minimum-visibility check existed, the confirm button was actionable the instant the dialog rendered.
- Drive an unintended confirm
A timed or overlaid tap triggers
onConfirm, which calledonConfirmSave(...)anddismiss()with no guard. - Silent commit The download is saved to the attacker-influenced name/location without the user consciously agreeing.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
Transitionmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt |
modified | |
RenameAndChangeLocationDialogFragmentmobile/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.ktmobile/android/fenix/app/src/main/java/org/mozilla/fenix/downloads/RenameAndChangeLocationDialogFragment.kt
Audit Directions
- Missing abuse guard on consequential dialogsAudit every Fenix
DialogFragmentwhose confirm action performs a filesystem, permission, or download decision to ensure it wires inPromptAbuserDetectorlike this dialog now does. - Timer anchored to visibilityVerify 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 bypassReview 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.
Patch
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()
+}