Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionInformation disclosure in the WebExtensions component in Firefox for Android
ComponentCore
Bug ClassLogic Error
Tracker1699444
Fix commit156251d663b4 (firefox) +138/-32
CISA KEVNot listed
CreditedWladimir Palant
Disclosed2026-09-01

Files Changed

  • mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt
  • mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtension.kt
  • mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt
  • mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt
  • mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtension.kt
  • mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt
  • mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/WebExtensionTest.kt
  • mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionSupport.kt
  • mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionSupportTest.kt
  • mobile/android/android-components/samples/browser/src/main/java/org/mozilla/samples/browser/SampleApplication.kt
  • mobile/android/fenix/app/src/main/java/org/mozilla/fenix/FenixApplication.kt
  • mobile/android/focus-android/app/src/main/java/org/mozilla/focus/FocusApplication.kt
  • mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerActivity.java
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt
index a6abfcb09a8..4168b63f02a 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt
@@ -152,8 +152,17 @@ class GeckoEngine(
         }
     }
     private val webExtensionTabHandler = object : TabHandler {
-        override fun onNewTab(webExtension: WebExtension, engineSession: EngineSession, active: Boolean, url: String) {
-            webExtensionDelegate?.onNewTab(webExtension, engineSession, active, url)
+        override fun isInPrivateBrowsing(): Boolean =
+            webExtensionDelegate?.isInPrivateBrowsing() ?: false
+
+        override fun onNewTab(
+            webExtension: WebExtension,
+            engineSession: EngineSession,
+            active: Boolean,
+            url: String,
+            isPrivate: Boolean,
+        ) {
+            webExtensionDelegate?.onNewTab(webExtension, engineSession, active, url, isPrivate)
         }
 
         override fun onOpenOptionsPage(extension: WebExtension) {
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtension.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtension.kt
index 8633cb91821..e611d576910 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtension.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtension.kt
@@ -260,8 +260,11 @@ class GeckoWebExtension(
                 ext: GeckoNativeWebExtension,
                 tabDetails: GeckoNativeWebExtension.CreateTabDetails,
             ): GeckoResult<GeckoSession>? {
+                // TODO bug 1372178: extensions cannot set (non-)privateness.
+                val isPrivate = tabHandler.isInPrivateBrowsing()
                 val geckoEngineSession = GeckoEngineSession(
                     runtime = runtime,
+                    privateMode = isPrivate,
                     defaultSettings = defaultSettings,
                     openGeckoSession = false,
                 )
@@ -271,6 +274,7 @@ class GeckoWebExtension(
                     geckoEngineSession,
                     tabDetails.active == true,
                     tabDetails.url ?: "",
+                    isPrivate,
                 )
                 return GeckoResult.fromValue(geckoEngineSession.geckoSession)
             }
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt
index 376a205c8d1..19f680f72b8 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt
@@ -1972,7 +1972,7 @@ class GeckoEngineTest {
         tabDelegateCaptor.value.onNewTab(extension, createTabDetails)
 
         val extensionCaptor = argumentCaptor<WebExtension>()
-        verify(webExtensionsDelegate).onNewTab(extensionCaptor.capture(), any(), eq(false), eq(""))
+        verify(webExtensionsDelegate).onNewTab(extensionCaptor.capture(), any(), eq(false), eq(""), eq(false))
         assertEquals(extId, extensionCaptor.value.id)
     }
 
@@ -2076,7 +2076,7 @@ class GeckoEngineTest {
         tabDelegateCaptor.value.onNewTab(extension, createTabDetails)
 
         val extensionCaptor = argumentCaptor<WebExtension>()
-        verify(webExtensionsDelegate).onNewTab(extensionCaptor.capture(), any(), eq(false), eq(""))
+        verify(webExtensionsDelegate).onNewTab(extensionCaptor.capture(), any(), eq(false), eq(""), eq(false))
         assertEquals(extId, extensionCaptor.value.id)
     }
 
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt
index 0115b874a8d..cd4f8f3731e 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt
@@ -340,13 +340,54 @@ class GeckoWebExtensionTest {
         // Verify that tab methods are forwarded to the handler
         val tabDetails = mockCreateTabDetails(active = true, url = "url")
         tabDelegateCaptor.value.onNewTab(nativeGeckoWebExt, tabDetails)
-        verify(tabHandler).onNewTab(eq(extension), engineSessionCaptor.capture(), eq(true), eq("url"))
+        verify(tabHandler).onNewTab(eq(extension), engineSessionCaptor.capture(), eq(true), eq("url"), eq(false))
         assertNotNull(engineSessionCaptor.value)
+        assertFalse(engineSessionCaptor.value.geckoSession.settings.usePrivateMode)
 
         tabDelegateCaptor.value.onOpenOptionsPage(nativeGeckoWebExt)
         verify(tabHandler).onOpenOptionsPage(eq(extension))
     }
 
+    @Test
+    fun `global tab handler opens a private tab when private browsing mode is active`() {
+        val runtime: GeckoRuntime = mock()
+        whenever(runtime.settings).thenReturn(mock())
+        whenever(runtime.webExtensionController).thenReturn(mock())
+        val tabHandler: TabHandler = mock()
+        val tabDelegateCaptor = argumentCaptor<WebExtension.TabDelegate>()
+        val engineSessionCaptor = argumentCaptor<GeckoEngineSession>()
+
+        val nativeGeckoWebExt: WebExtension =
+            mockNativeWebExtension(id = "id", location = "uri", metaData = mockNativeWebExtensionMetaData())
+
+        // Create extension and register global tab handler
+        val extension = GeckoWebExtension(
+            runtime = runtime,
+            nativeExtension = nativeGeckoWebExt,
+        )
+        val defaultSettings: DefaultSettings = mock()
+
+        // Simulate that the user has enabled private browsing.
+        whenever(tabHandler.isInPrivateBrowsing()).thenReturn(true)
+
+        extension.registerTabHandler(tabHandler, defaultSettings)
+        verify(nativeGeckoWebExt).tabDelegate = tabDelegateCaptor.capture()
+
+        // Simulate browser.tabs.create() call from extension.
+        val tabDetails = mockCreateTabDetails(active = true, url = "url")
+        tabDelegateCaptor.value.onNewTab(nativeGeckoWebExt, tabDetails)
+        verify(tabHandler).onNewTab(eq(extension), engineSessionCaptor.capture(), eq(true), eq("url"), eq(true))
+        assertTrue(engineSessionCaptor.value.geckoSession.settings.usePrivateMode)
+
+        // Simulate that the user switched back to non-private browsing.
+        whenever(tabHandler.isInPrivateBrowsing()).thenReturn(false)
+
+        // Now browser.tabs.create() should open in a non-private browsing context.
+        tabDelegateCaptor.value.onNewTab(nativeGeckoWebExt, tabDetails)
+        verify(tabHandler).onNewTab(eq(extension), engineSessionCaptor.capture(), eq(true), eq("url"), eq(false))
+        assertFalse(engineSessionCaptor.value.geckoSession.settings.usePrivateMode)
+    }
+
     @Test
     fun `register session-specific tab handler`() {
         val runtime: GeckoRuntime = mock()
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtension.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtension.kt
index 1c22cc1e5bb..ee98a9dbe1c 100644
--- a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtension.kt
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtension.kt
@@ -302,6 +302,11 @@ interface MessageHandler {
  * A handler for all tab related events (triggered by browser.tabs.* methods).
  */
 interface TabHandler {
+    /**
+     * Invoked to determine the current private browsing mode. New tabs opened
+     * by extensions may use this state if not specified otherwise.
+     */
+    fun isInPrivateBrowsing(): Boolean = false
 
     /**
      * Invoked when a web extension attempts to open a new tab via
@@ -311,8 +316,16 @@ interface TabHandler {
      * @param engineSession an instance of engine session to open a new tab with.
      * @param active whether or not the new tab should be active/selected.
      * @param url the target url to be loaded in a new tab.
-     */
-    fun onNewTab(webExtension: WebExtension, engineSession: EngineSession, active: Boolean, url: String) = Unit
+     * @param isPrivate whether private browsing mode is enabled for the new
+     * tab. Must match the engineSession.privateMode flag.
+     */
+    fun onNewTab(
+        webExtension: WebExtension,
+        engineSession: EngineSession,
+        active: Boolean,
+        url: String,
+        isPrivate: Boolean,
+    ) = Unit
 
     /**
      * Invoked when a web extension attempts to update a tab via
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt
index 86eeb61a442..11827ac028e 100644
--- a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt
@@ -62,6 +62,12 @@ interface WebExtensionDelegate {
      */
     fun onAllowedInPrivateBrowsingChanged(extension: WebExtension) = Unit
 
+    /**
+     * Invoked to determine the current private browsing mode. New tabs opened
+     * by extensions may use this state if not specified otherwise.
+     */
+    fun isInPrivateBrowsing(): Boolean = false
+
     /**
      * Invoked when a web extension attempts to open a new tab via
      * browser.tabs.create. Note that browser.tabs.update and browser.tabs.remove
@@ -72,8 +78,16 @@ interface WebExtensionDelegate {
      * @param engineSession an instance of engine session to open a new tab with.
      * @param active whether or not the new tab should be active/selected.
      * @param url the target url to be loaded in a new tab.
+     * @param isPrivate whether private browsing mode is enabled for the new
+     * tab. Must match the engineSession.privateMode flag.
      */
-    fun onNewTab(extension: WebExtension, engineSession: EngineSession, active: Boolean, url: String) = Unit
+    fun onNewTab(
+        extension: WebExtension,
+        engineSession: EngineSession,
+        active: Boolean,
+        url: String,
+        isPrivate: Boolean,
+    ) = Unit
 
     /**
      * Invoked when a web extension defines a browser action. To listen for session-specific
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt
index 376a205c8d1..19f680f72b8 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt
@@ -1972,7 +1972,7 @@ class GeckoEngineTest {
         tabDelegateCaptor.value.onNewTab(extension, createTabDetails)
 
         val extensionCaptor = argumentCaptor<WebExtension>()
-        verify(webExtensionsDelegate).onNewTab(extensionCaptor.capture(), any(), eq(false), eq(""))
+        verify(webExtensionsDelegate).onNewTab(extensionCaptor.capture(), any(), eq(false), eq(""), eq(false))
         assertEquals(extId, extensionCaptor.value.id)
     }
 
@@ -2076,7 +2076,7 @@ class GeckoEngineTest {
         tabDelegateCaptor.value.onNewTab(extension, createTabDetails)
 
         val extensionCaptor = argumentCaptor<WebExtension>()
-        verify(webExtensionsDelegate).onNewTab(extensionCaptor.capture(), any(), eq(false), eq(""))
+        verify(webExtensionsDelegate).onNewTab(extensionCaptor.capture(), any(), eq(false), eq(""), eq(false))
         assertEquals(extId, extensionCaptor.value.id)
     }
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt
index 0115b874a8d..cd4f8f3731e 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt
@@ -340,13 +340,54 @@ class GeckoWebExtensionTest {
         // Verify that tab methods are forwarded to the handler
         val tabDetails = mockCreateTabDetails(active = true, url = "url")
         tabDelegateCaptor.value.onNewTab(nativeGeckoWebExt, tabDetails)
-        verify(tabHandler).onNewTab(eq(extension), engineSessionCaptor.capture(), eq(true), eq("url"))
+        verify(tabHandler).onNewTab(eq(extension), engineSessionCaptor.capture(), eq(true), eq("url"), eq(false))
         assertNotNull(engineSessionCaptor.value)
+        assertFalse(engineSessionCaptor.value.geckoSession.settings.usePrivateMode)
 
         tabDelegateCaptor.value.onOpenOptionsPage(nativeGeckoWebExt)
         verify(tabHandler).onOpenOptionsPage(eq(extension))
     }
 
+    @Test
+    fun `global tab handler opens a private tab when private browsing mode is active`() {
+        val runtime: GeckoRuntime = mock()
+        whenever(runtime.settings).thenReturn(mock())
+        whenever(runtime.webExtensionController).thenReturn(mock())
+        val tabHandler: TabHandler = mock()
+        val tabDelegateCaptor = argumentCaptor<WebExtension.TabDelegate>()
+        val engineSessionCaptor = argumentCaptor<GeckoEngineSession>()
+
+        val nativeGeckoWebExt: WebExtension =
+            mockNativeWebExtension(id = "id", location = "uri", metaData = mockNativeWebExtensionMetaData())
+
+        // Create extension and register global tab handler
+        val extension = GeckoWebExtension(
+            runtime = runtime,
+            nativeExtension = nativeGeckoWebExt,
+        )
+        val defaultSettings: DefaultSettings = mock()
+
+        // Simulate that the user has enabled private browsing.
+        whenever(tabHandler.isInPrivateBrowsing()).thenReturn(true)
+
+        extension.registerTabHandler(tabHandler, defaultSettings)
+        verify(nativeGeckoWebExt).tabDelegate = tabDelegateCaptor.capture()
+
+        // Simulate browser.tabs.create() call from extension.
+        val tabDetails = mockCreateTabDetails(active = true, url = "url")
+        tabDelegateCaptor.value.onNewTab(nativeGeckoWebExt, tabDetails)
+        verify(tabHandler).onNewTab(eq(extension), engineSessionCaptor.capture(), eq(true), eq("url"), eq(true))
+        assertTrue(engineSessionCaptor.value.geckoSession.settings.usePrivateMode)
+
+        // Simulate that the user switched back to non-private browsing.
+        whenever(tabHandler.isInPrivateBrowsing()).thenReturn(false)
+
+        // Now browser.tabs.create() should open in a non-private browsing context.
+        tabDelegateCaptor.value.onNewTab(nativeGeckoWebExt, tabDetails)
+        verify(tabHandler).onNewTab(eq(extension), engineSessionCaptor.capture(), eq(true), eq("url"), eq(false))
+        assertFalse(engineSessionCaptor.value.geckoSession.settings.usePrivateMode)
+    }
+
     @Test
     fun `register session-specific tab handler`() {
         val runtime: GeckoRuntime = mock()
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/WebExtensionTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/WebExtensionTest.kt
index 39e9e766ebc..50ebd89e4b1 100644
--- a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/WebExtensionTest.kt
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/WebExtensionTest.kt
@@ -31,7 +31,7 @@ class WebExtensionTest {
 
         tabHandler.onUpdateTab(mock(), mock(), false, "")
         tabHandler.onCloseTab(mock(), mock())
-        tabHandler.onNewTab(mock(), mock(), false, "")
+        tabHandler.onNewTab(mock(), mock(), false, "", false)
     }
 
     @Test
diff --git a/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionSupportTest.kt b/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionSupportTest.kt
index e31dd34ee26..8fc701e7efe 100644
--- a/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionSupportTest.kt
+++ b/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionSupportTest.kt
@@ -146,7 +146,7 @@ class WebExtensionSupportTest {
         WebExtensionSupport.initialize(engine, store)
         verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
 
-        delegateCaptor.value.onNewTab(ext, engineSession, true, "https://mozilla.org")
+        delegateCaptor.value.onNewTab(ext, engineSession, true, "https://mozilla.org", false)
 
         captureMiddleware.assertFirstAction(TabListAction.AddTabAction::class) { action ->
             assertEquals("https://mozilla.org", action.tab.content.url)
@@ -169,14 +169,14 @@ class WebExtensionSupportTest {
         WebExtensionSupport.initialize(
             engine,
             store,
-            onNewTabOverride = { _, _, _, _ ->
+            onNewTabOverride = { _, _, _, _, _ ->
                 onNewTabCalled = true
                 "123"
             },
         )
         verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
 
-        delegateCaptor.value.onNewTab(ext, engineSession, true, "https://mozilla.org")
+        delegateCaptor.value.onNewTab(ext, engineSession, true, "https://mozilla.org", false)
         assertTrue(onNewTabCalled)
     }
 
@@ -193,7 +193,7 @@ class WebExtensionSupportTest {
         WebExtensionSupport.initialize(
             engine,
             store,
-            onNewTabOverride = { _, _, _, selected ->
+            onNewTabOverride = { _, _, _, selected, _ ->
                 capturedSelected.add(selected)
                 "session-${capturedSelected.size}"
             },
@@ -203,8 +203,8 @@ class WebExtensionSupportTest {
         )
         verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
 
-        delegateCaptor.value.onNewTab(ext, engineSession, true, "https://mozilla.org")
-        delegateCaptor.value.onNewTab(ext, engineSession, false, "https://mozilla.org")
+        delegateCaptor.value.onNewTab(ext, engineSession, true, "https://mozilla.org", false)
+        delegateCaptor.value.onNewTab(ext, engineSession, false, "https://mozilla.org", false)
 
         assertEquals(listOf(true, false), capturedSelected)
         // onSelectTabOverride should only fire for the active=true case; the override
@@ -223,7 +223,7 @@ class WebExtensionSupportTest {
         WebExtensionSupport.initialize(engine, store)
         verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
 
-        delegateCaptor.value.onNewTab(ext, engineSession, false, "https://mozilla.org")
+        delegateCaptor.value.onNewTab(ext, engineSession, false, "https://mozilla.org", false)
 
         captureMiddleware.assertFirstAction(TabListAction.AddTabAction::class) { action ->
             assertEquals("https://mozilla.org", action.tab.content.url)
Loading diff…