CVE-2026-74975
Overview
Background
- `DownloadsFeature`
- The android-components feature class that mediates web-initiated downloads and the dialogs shown to the user before a download proceeds.
- Custom first-party download dialog
- An app-supplied dialog (via the
customFirstPartyDownloadDialogdelegate) shown for a same-origin download, including its optional rename/change-location sub-dialog. - `dismissAllDownloadDialogs`
- The internal
DownloadsFeatureroutine meant to tear down every outstanding download-related dialog, notably when the page or tab changes. - UI spoofing
- An attack where stale or misattributed UI (here, a download dialog left over from a previous site) misleads the user about the origin or nature of what they are acting on.
Root Cause Analysis
Before the fix, DownloadsFeature.dismissAllDownloadDialogs only dismissed the two built-in dialogs via findPreviousDownloadDialogFragment()?.dismiss() and findPreviousAppDownloaderDialogFragment()?.dismiss(), but had no reference to the app-provided customFirstPartyDownloadDialog (and its associated RenameAndChangeLocationDialogFragment), so those custom dialogs were never torn down. The invariant that “all download dialogs are dismissed when download state is cleared or the user navigates away” was therefore violated for the custom dialog path. This meant a custom first-party download dialog raised for one site could remain on screen after the tab navigated to another site, presenting stale download context over unrelated content.
The fix adds a dismissCustomFirstPartyDownloadDialog callback parameter to DownloadsFeature and invokes it inside dismissAllDownloadDialogs, and the Fenix consumers (BaseBrowserFragment, AddonPopupBaseFragment) wire that callback to dismiss both the rename dialog and their tracked downloadDialog. Because dismissAllDownloadDialogs is already reached on navigation and download-state changes, routing the custom dialog through the same teardown restores the invariant for every dialog type.
dismissAllDownloadDialogs knew how to close only the framework-owned dialogs and had no hook to close the app-owned custom first-party (and rename) dialogs; the fix supplies that hook as the dismissCustomFirstPartyDownloadDialog callback and invokes it during the same teardown so no download dialog survives a navigation.Attack Path
- Trigger a custom download dialog A malicious page initiates a first-party download, causing Firefox for Android to display the custom first-party download dialog (or its rename dialog).
- Navigate the tab away The page (or the attacker’s script/redirect) navigates the tab to a different, attacker-chosen site while the dialog is still displayed.
- Stale dialog persists
Because
dismissAllDownloadDialogsnever dismissed the custom dialog, it remains overlaid on top of the new site’s content. - Misattribute the prompt The user perceives the leftover download prompt as belonging to the newly loaded page, and may confirm a download or filename under a false impression of its origin.
Impact Assessment
Files Changed
mobile/android/android-components/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadsFeature.ktmobile/android/android-components/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadsFeatureTest.ktmobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/AddonPopupBaseFragment.ktmobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt
Audit Directions
- Incomplete dialog/overlay teardownAudit every centralized “dismiss all” or navigation-cleanup routine to confirm it accounts for app-injected or delegate-owned UI, not just framework-owned fragments.
- Delegate without paired lifecycle hookWhen a component accepts a callback to show custom UI (like
customFirstPartyDownloadDialog), verify a corresponding dismiss hook exists and is invoked on navigation and state reset. - Stale UI across navigationReview flows where a dialog or prompt tied to one origin can outlive a tab/URL change, since surviving UI enables origin-confusion spoofing.
Patch
diff --git a/mobile/android/android-components/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadsFeature.kt b/mobile/android/android-components/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadsFeature.kt
index 896701bbd5c..eefbf26fb7e 100644
--- a/mobile/android/android-components/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadsFeature.kt
+++ b/mobile/android/android-components/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadsFeature.kt
@@ -123,6 +123,8 @@ value class OpenFileCallback(val value: () -> Unit)
* manager is provided, a dialog will be shown before every download.
* @property promptsStyling styling properties for the dialog.
* @property onDownloadStartedListener a callback invoked when a download is started.
+ * @property dismissCustomFirstPartyDownloadDialog A callback invoked when the custom first party
+ * download dialog should be dismissed.
* @property shouldForwardToThirdParties Indicates if downloads should be forward to third party apps,
* if there are multiple apps a chooser dialog will shown.
* @property customFirstPartyDownloadDialog An optional delegate for showing a dialog for a download
@@ -146,6 +148,7 @@ class DownloadsFeature(
private val fragmentManager: FragmentManager? = null,
private val promptsStyling: PromptsStyling? = null,
private val onDownloadStartedListener: ((String) -> Unit) = {},
+ private val dismissCustomFirstPartyDownloadDialog: () -> Unit = {},
private val shouldForwardToThirdParties: () -> Boolean = { false },
private val customFirstPartyDownloadDialog: (
(
@@ -556,6 +559,7 @@ class DownloadsFeature(
internal fun dismissAllDownloadDialogs() {
findPreviousDownloadDialogFragment()?.dismiss()
findPreviousAppDownloaderDialogFragment()?.dismiss()
+ dismissCustomFirstPartyDownloadDialog.invoke()
}
private val ActivityInfo.identifier: String get() = packageName + name
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/AddonPopupBaseFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/AddonPopupBaseFragment.kt
index 2b99f6a892a..08200d7052e 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/AddonPopupBaseFragment.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/AddonPopupBaseFragment.kt
@@ -161,6 +161,10 @@ abstract class AddonPopupBaseFragment :
onNeedToRequestPermissions = { permissions ->
requestPermissions(permissions, REQUEST_CODE_DOWNLOAD_PERMISSIONS)
},
+ dismissCustomFirstPartyDownloadDialog = {
+ dismissRenameDialog()
+ downloadDialog?.dismiss()
+ },
customFirstPartyDownloadDialog = { currentDownloadState, _, positiveAction, negativeAction, _ ->
run {
if (canShowDownloadDialog()) {
@@ -394,6 +398,13 @@ abstract class AddonPopupBaseFragment :
return downloadDialog == null && !isRenameFragmentShowing
}
+ private fun dismissRenameDialog() {
+ val renameDialog = childFragmentManager.findFragmentByTag(
+ RenameAndChangeLocationDialogFragment.RENAME_AND_CHANGE_LOCATION_DIALOG_TAG,
+ ) as? RenameAndChangeLocationDialogFragment
+ renameDialog?.dismissAllowingStateLoss()
+ }
+
/**
* Forwards activity results to the [ActivityResultHandler] features.
*/
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt
index 314b0b195a5..b2eeea2f8b5 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt
@@ -742,6 +742,10 @@ abstract class BaseBrowserFragment :
onNeedToRequestPermissions = { permissions ->
requestPermissions(permissions, REQUEST_CODE_DOWNLOAD_PERMISSIONS)
},
+ dismissCustomFirstPartyDownloadDialog = {
+ dismissRenameDialog()
+ dismissDownloadDialogs()
+ },
customFirstPartyDownloadDialog = {
currentDownloadState,
fileNameIfAlreadyDownloaded,
diff --git a/mobile/android/android-components/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadsFeatureTest.kt b/mobile/android/android-components/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadsFeatureTest.kt
index ee4f52f8e7a..5dc52d87826 100644
--- a/mobile/android/android-components/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadsFeatureTest.kt
+++ b/mobile/android/android-components/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadsFeatureTest.kt
@@ -1460,6 +1460,64 @@ class DownloadsFeatureTest {
verify(cancelDownloadRequestUseCase).invoke(anyString(), anyString())
}
+ @Test
+ fun `GIVEN a custom download dialog is used WHEN dismissAllDownloadDialogs is called THEN the dialog is dismissed`() = runTest(testDispatcher) {
+ val dismissCustomDialog = mock<() -> Unit>()
+ val feature = DownloadsFeature(
+ testContext,
+ store,
+ useCases = DownloadsUseCases(store, mock()),
+ downloadFileUtils = FakeDownloadFileUtils(),
+ downloadManager = mock(),
+ mainDispatcher = testDispatcher,
+ dismissCustomFirstPartyDownloadDialog = dismissCustomDialog,
+ )
+
+ feature.dismissAllDownloadDialogs()
+
+ verify(dismissCustomDialog).invoke()
+ }
+
+ @Test
+ fun `GIVEN a custom download dialog is used WHEN navigating to another website THEN the dialog is dismissed`() = runTest(testDispatcher) {
+ val dismissCustomDialog = mock<() -> Unit>()
+ val downloadsUseCases = spy(DownloadsUseCases(store, mock()))
+ val cancelDownloadRequestUseCase = mock<CancelDownloadRequestUseCase>()
+ val download = DownloadState(url = "https://www.mozilla.org", sessionId = "test-tab")
+ store.dispatch(ContentAction.UpdateDownloadAction("test-tab", download = download))
+
+ doReturn(cancelDownloadRequestUseCase).`when`(downloadsUseCases).cancelDownloadRequest
+
+ val feature = spy(
+ DownloadsFeature(
+ testContext,
+ store,
+ useCases = downloadsUseCases,
+ downloadFileUtils = FakeDownloadFileUtils(),
+ downloadManager = mock(),
+ mainDispatcher = testDispatcher,
+ dismissCustomFirstPartyDownloadDialog = dismissCustomDialog,
+ ),
+ )
+
+ doReturn(true).`when`(feature).processDownload(any(), any())
+
+ feature.start()
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ store.dispatch(ContentAction.UpdateDownloadAction("test-tab", download = download))
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ grantPermissions()
+
+ val tab = createTab("https://www.firefox.com")
+ store.dispatch(TabListAction.AddTabAction(tab, select = true))
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ verify(feature).dismissAllDownloadDialogs()
+ verify(dismissCustomDialog).invoke()
+ }
+
@Test
fun `ResolveInfo to DownloaderApps`() = runTest(testDispatcher) {
val spyContext = spy(testContext)
Regression Test / PoC
diff --git a/mobile/android/android-components/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadsFeatureTest.kt b/mobile/android/android-components/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadsFeatureTest.kt
index ee4f52f8e7a..5dc52d87826 100644
--- a/mobile/android/android-components/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadsFeatureTest.kt
+++ b/mobile/android/android-components/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadsFeatureTest.kt
@@ -1460,6 +1460,64 @@ class DownloadsFeatureTest {
verify(cancelDownloadRequestUseCase).invoke(anyString(), anyString())
}
+ @Test
+ fun `GIVEN a custom download dialog is used WHEN dismissAllDownloadDialogs is called THEN the dialog is dismissed`() = runTest(testDispatcher) {
+ val dismissCustomDialog = mock<() -> Unit>()
+ val feature = DownloadsFeature(
+ testContext,
+ store,
+ useCases = DownloadsUseCases(store, mock()),
+ downloadFileUtils = FakeDownloadFileUtils(),
+ downloadManager = mock(),
+ mainDispatcher = testDispatcher,
+ dismissCustomFirstPartyDownloadDialog = dismissCustomDialog,
+ )
+
+ feature.dismissAllDownloadDialogs()
+
+ verify(dismissCustomDialog).invoke()
+ }
+
+ @Test
+ fun `GIVEN a custom download dialog is used WHEN navigating to another website THEN the dialog is dismissed`() = runTest(testDispatcher) {
+ val dismissCustomDialog = mock<() -> Unit>()
+ val downloadsUseCases = spy(DownloadsUseCases(store, mock()))
+ val cancelDownloadRequestUseCase = mock<CancelDownloadRequestUseCase>()
+ val download = DownloadState(url = "https://www.mozilla.org", sessionId = "test-tab")
+ store.dispatch(ContentAction.UpdateDownloadAction("test-tab", download = download))
+
+ doReturn(cancelDownloadRequestUseCase).`when`(downloadsUseCases).cancelDownloadRequest
+
+ val feature = spy(
+ DownloadsFeature(
+ testContext,
+ store,
+ useCases = downloadsUseCases,
+ downloadFileUtils = FakeDownloadFileUtils(),
+ downloadManager = mock(),
+ mainDispatcher = testDispatcher,
+ dismissCustomFirstPartyDownloadDialog = dismissCustomDialog,
+ ),
+ )
+
+ doReturn(true).`when`(feature).processDownload(any(), any())
+
+ feature.start()
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ store.dispatch(ContentAction.UpdateDownloadAction("test-tab", download = download))
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ grantPermissions()
+
+ val tab = createTab("https://www.firefox.com")
+ store.dispatch(TabListAction.AddTabAction(tab, select = true))
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ verify(feature).dismissAllDownloadDialogs()
+ verify(dismissCustomDialog).invoke()
+ }
+
@Test
fun `ResolveInfo to DownloaderApps`() = runTest(testDispatcher) {
val spyContext = spy(testContext)