CVE-2026-74951
Overview
Background
- Clickjacking
- a UI-redress attack where a malicious page tricks a user into activating a security-sensitive control before they can perceive it.
- `SitePermissionsDialogFragment`
- the android-components dialog that asks the user to grant a web page a site permission (camera, location, notifications, etc.).
- `PromptAbuserDetector`
- a helper that suppresses or delays dialog actions to defend against pages that abuse prompts, exposing
updateJSDialogAbusedState()to arm a click delay. - `onEnterAnimationComplete()`
- an Android
Activitycallback that fires once the activity’s entering (window-open) animation has finished rendering.
Root Cause Analysis
When a SitePermissionsDialogFragment was presented during a Custom Tab / ExternalAppBrowserActivity launch, its positive (“Allow”) button became clickable while the activity’s enter animation was still playing, so the dialog could accept a tap before it was fully drawn and perceivable by the user. The security invariant that a permission-granting control must not act on input until the user has had a chance to see and understand it was violated, because no click-delay was armed at the moment the dialog surfaced.
The fix introduces an OnEnterAnimationCompleteListener interface and has SitePermissionsDialogFragment implement it, so that when the hosting activity finishes its enter animation the fragment calls promptAbuserDetector.updateJSDialogAbusedState() to extend the positive-button click delay. ExternalAppBrowserActivity.onEnterAnimationComplete() now walks its fragment tree and forwards the event to any fragment implementing the listener, wiring the callback through to the dialog. This works because the delay is (re)armed precisely when the window becomes visible, closing the window in which a pre-rendered or animation-obscured tap could be consumed.
onEnterAnimationComplete() to the dialog so it re-arms the PromptAbuserDetector click delay exactly when the UI becomes visible.Attack Path
- Lure into a Custom Tab
A malicious app or site opens a page in a Firefox Custom Tab backed by
ExternalAppBrowserActivity, whose launch plays an enter animation. - Trigger a permission prompt
The page requests a site permission, causing
SitePermissionsDialogFragmentto be shown while the activity is still animating in. - Land a tap during the animation A tap is delivered to the “Allow” button before the dialog is fully rendered and before any abuse delay is armed, so the click is accepted.
- Silent grant The user unknowingly grants the requested site permission to the attacker-controlled page.
Impact Assessment
ExternalAppBrowserActivity context. Exploitation requires timing a tap to coincide with the activity’s enter animation while the permission dialog is surfacing. Rated moderate; no memory-safety or code-execution primitive is involved.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifmobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragment.kt |
modified | |
ifmobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt |
modified |
Files Changed
mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragment.ktmobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/OnEnterAnimationCompleteListener.ktmobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt
Audit Directions
- Sensitive controls live before visibilityaudit dialogs and prompts whose accept/grant buttons are interactive before the hosting
Activityor window has finished its enter animation and become perceivable. - Missing abuse-delay arminglook for permission or JS dialogs presented without invoking
PromptAbuserDetector(e.g.updateJSDialogAbusedState()) at the moment they surface. - Unforwarded lifecycle callbackscheck activity-level callbacks like
onEnterAnimationComplete()that fragments need but are not propagated through theFragmentManagertree.
Patch
diff --git a/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragment.kt b/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragment.kt
index 8a9fdbc2b75..5cec43edd73 100644
--- a/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragment.kt
+++ b/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragment.kt
@@ -32,6 +32,7 @@ import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.content.appName
import mozilla.components.support.ktx.kotlin.ifNullOrEmpty
import mozilla.components.support.ktx.util.PromptAbuserDetector
+import mozilla.components.support.utils.OnEnterAnimationCompleteListener
internal const val KEY_SESSION_ID = "KEY_SESSION_ID"
internal const val KEY_TITLE = "KEY_TITLE"
@@ -51,7 +52,9 @@ private const val KEY_IS_NOTIFICATION_REQUEST = "KEY_IS_NOTIFICATION_REQUEST"
private const val DEFAULT_VALUE = Int.MAX_VALUE
private const val KEY_PERMISSION_ID = "KEY_PERMISSION_ID"
-internal open class SitePermissionsDialogFragment : NoObscuredTouchesDialogFragment() {
+internal open class SitePermissionsDialogFragment :
+ NoObscuredTouchesDialogFragment(),
+ OnEnterAnimationCompleteListener {
private val logger = Logger("SitePermissionsDialogFragment")
@@ -134,6 +137,11 @@ internal open class SitePermissionsDialogFragment : NoObscuredTouchesDialogFragm
feature?.onDismiss(permissionRequestId, sessionId)
}
+ override fun onEnterAnimationComplete() {
+ // Extend the positive button click delay.
+ promptAbuserDetector.updateJSDialogAbusedState()
+ }
+
private fun Dialog.setContainerView(rootView: View) {
if (dialogShouldWidthMatchParent) {
setContentView(rootView)
diff --git a/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/OnEnterAnimationCompleteListener.kt b/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/OnEnterAnimationCompleteListener.kt
new file mode 100644
index 00000000000..e28d6fa8a4c
--- /dev/null
+++ b/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/OnEnterAnimationCompleteListener.kt
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.support.utils
+
+/**
+ * Allows forwarding [android.app.Activity.onEnterAnimationComplete] to other classes
+ * (e.g. fragments) that want to participate in handling it.
+ */
+interface OnEnterAnimationCompleteListener {
+ /**
+ * Called when the Activity's entering animation has completed.
+ */
+ fun onEnterAnimationComplete()
+}
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt
index 4eea492349c..d5d5327685f 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt
@@ -11,6 +11,7 @@ import androidx.annotation.VisibleForTesting
import androidx.core.net.toUri
import mozilla.components.browser.state.selector.findCustomTab
import mozilla.components.browser.state.state.SessionState
+import mozilla.components.support.utils.OnEnterAnimationCompleteListener
import mozilla.components.support.utils.SafeIntent
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.ext.components
@@ -92,5 +93,14 @@ open class ExternalAppBrowserActivity : HomeActivity() {
override fun onEnterAnimationComplete() {
super.onEnterAnimationComplete()
isFinishedAnimating = true
+
+ val fragments = supportFragmentManager.fragments.toMutableList()
+ while (fragments.isNotEmpty()) {
+ val fragment = fragments.removeAt(0)
+ if (fragment is OnEnterAnimationCompleteListener) {
+ fragment.onEnterAnimationComplete()
+ }
+ fragments.addAll(fragment.childFragmentManager.fragments)
+ }
}
}