Low firefox Spoofing 🔧 Commit mapped

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionSpoofing issue in the Downloads component in Firefox for Android
ComponentCore
Bug ClassSpoofing
Tracker1842361
Fix commite90ea19c49fc (firefox) +77/-0
CISA KEVNot listed
CreditedHafiizh
Disclosed2026-08-18

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 customFirstPartyDownloadDialog delegate) shown for a same-origin download, including its optional rename/change-location sub-dialog.
`dismissAllDownloadDialogs`
The internal DownloadsFeature routine 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.

Key insight
The single mistake was that 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

  1. 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).
  2. 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.
  3. Stale dialog persists Because dismissAllDownloadDialogs never dismissed the custom dialog, it remains overlaid on top of the new site’s content.
  4. 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

An attacker gains a low-severity UI spoofing primitive: a download dialog raised for one origin can linger over an unrelated page, misleading the user about which site the download pertains to. This occurs in the Firefox for Android (Fenix) browser UI process, in the download-confirmation flow. The precondition is that the app uses the custom first-party download dialog and the user navigates while such a dialog is open; no memory-safety or code-execution capability is implied by the diff.

Files Changed

  • mobile/android/android-components/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadsFeature.kt
  • mobile/android/android-components/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadsFeatureTest.kt
  • mobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/AddonPopupBaseFragment.kt
  • mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt

Audit Directions

  • Incomplete dialog/overlay teardown
    Audit 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 hook
    When 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 navigation
    Review flows where a dialog or prompt tied to one origin can outlive a tab/URL change, since surviving UI enables origin-confusion spoofing.
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…