Medium CVSS 7.8 firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
7.8
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionMalicious pages could use Firefox for Android to pass FIDO: links to the OS and trigger the "hybrid" passkey transport. An attacker within Bluetooth range could have used this to trick the user into using their passkey to log the attacker's computer into the target account.
ComponentCore
Bug ClassLogic Error
Tracker1922357
Fix commitbff9465f2eae (firefox) +28/-2
CVSS vectorCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedMasterSplinter
Disclosed2025-03-04

Background

`fido:` URI scheme
a URI scheme used by the WebAuthn “hybrid” (caBLE) transport that, when handed to the Android OS, invokes the platform passkey flow to authenticate over Bluetooth to a nearby device.
App links / external intents
Android’s mechanism where a browser hands a URL it does not handle itself (a non-web scheme) to another installed app or the OS via an Intent, rather than loading it as a page.
`BLOCKED_SCHEMES`
the allowlist-inverse in GeckoEngineSession that lists URI schemes the engine must refuse to load directly.
`ALWAYS_DENY_SCHEMES`
the set in AppLinksUseCases enumerating schemes that must never be treated as an external app link and dispatched to the OS.

Root Cause Analysis

On Firefox for Android, a navigation to a URL whose scheme the Gecko engine does not render is routed through the app-links machinery, which resolves an external Intent and hands the URI to the operating system. The fido: scheme was absent from every guard on this path: it was not in GeckoEngineSession.BLOCKED_SCHEMES, not in AppLinksUseCases.ALWAYS_DENY_SCHEMES, and IntentUtils.isUriSafeForScheme returned true for it. Because a scriptable page could trigger navigation to an arbitrary fido: URI, a malicious site could cause the browser to pass a fido: link to the OS, launching the platform “hybrid” passkey transport without a genuine, user-initiated WebAuthn ceremony. The invariant violated is that sensitive OS-level schemes tied to credential/authenticator flows must not be reachable from untrusted web content.

The fix adds "fido" to both scheme deny-lists and makes isUriSafeForScheme explicitly return false for fido:, so the engine neither loads the URI nor forwards it as an external intent.

Key insight
The core mistake was omitting the security-sensitive fido: scheme from the browser’s scheme deny-lists, leaving a web-reachable path that forwarded it to the OS; the fix closes that path by adding fido to BLOCKED_SCHEMES and ALWAYS_DENY_SCHEMES and rejecting it in isUriSafeForScheme.

Attack Path

  1. Lure the victim An attacker gets the target to open a malicious page in Firefox for Android while the attacker’s machine sits within Bluetooth range.
  2. Trigger a `fido:` navigation The page navigates to a crafted fido: URI; Gecko does not render this scheme, so it falls through to the external app-link resolver.
  3. Escape to the OS Because fido is missing from BLOCKED_SCHEMES / ALWAYS_DENY_SCHEMES and isUriSafeForScheme returns true, the URI is packaged as an Intent and handed to Android.
  4. Invoke the hybrid transport The OS launches the “hybrid”/caBLE passkey flow, prompting the user to authenticate with their passkey.
  5. Hijack the ceremony The attacker’s nearby computer is the caBLE peer, so the victim’s passkey authenticates the attacker’s login session to the target account.

Impact Assessment

An attacker within Bluetooth range gains the ability to log their own computer into the victim’s account by tricking the user into completing a passkey ceremony that the malicious web page silently initiated. This occurs in the Firefox for Android browser’s navigation/app-link handling, which runs with the browser’s ability to dispatch OS intents. Preconditions are that the victim opens attacker-controlled content in Firefox for Android and the attacker is positioned within Bluetooth range to act as the hybrid transport peer.

Files Changed

  • mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
  • mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
  • mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt
  • mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
  • mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
  • mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java

Audit Directions

  • Scheme deny-list completeness
    Cross-check every scheme guard (BLOCKED_SCHEMES, ALWAYS_DENY_SCHEMES, isUriSafeForScheme) against the full set of OS/authenticator schemes to find any privileged scheme still reachable from web content.
  • Web-reachable OS handoff paths
    Trace every code path where an unhandled navigation falls through to Intent resolution and confirm each is gated so untrusted pages cannot silently launch OS-level flows.
  • Case and normalization gaps
    Verify scheme comparisons are case-insensitive and normalized so variants like FIDO: or padded/encoded forms cannot bypass the deny-lists.
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
index 0980c997796..2e2898ffb9e 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
@@ -1553,7 +1553,7 @@ class GeckoEngineSession(
         internal const val ABOUT_BLANK = "about:blank"
         internal const val JS_SCHEME = "javascript"
         internal val BLOCKED_SCHEMES =
-            listOf("file", "resource", JS_SCHEME) // See 1684761 and 1684947
+            listOf("file", "resource", "fido", JS_SCHEME) // See 1684761 and 1684947
 
         /**
          * Provides an ErrorType corresponding to the error code provided.
diff --git a/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt b/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt
index be9c48c9d7c..5fc4e22ba5f 100644
--- a/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt
+++ b/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt
@@ -325,6 +325,7 @@ class AppLinksUseCases(
             "https", "moz-extension", "moz-safe-about", "resource", "view-source", "ws", "wss", "blob",
         )
 
-        internal val ALWAYS_DENY_SCHEMES: Set<String> = setOf("jar", "file", "javascript", "data", "about", "content")
+        internal val ALWAYS_DENY_SCHEMES: Set<String> =
+            setOf("jar", "file", "javascript", "data", "about", "content", "fido")
     }
 }
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
index 36fde18a02e..ff582dfe33b 100644
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
@@ -76,6 +76,10 @@ public class IntentUtils {
       return getSafeIntent(aUri) != null;
     }
 
+    if ("fido".equals(scheme)) {
+      return false;
+    }
+
     return true;
   }
 
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
index f5b121169d4..ef7087a4cdd 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
@@ -637,6 +637,11 @@ class GeckoEngineSessionTest {
         engineSession.loadUrl("RESOURCE://package/test.text")
         verify(geckoSession, never()).load(GeckoSession.Loader().uri("resource://package/test.text"))
         verify(geckoSession, never()).load(GeckoSession.Loader().uri("RESOURCE://package/test.text"))
+
+        engineSession.loadUrl("fido:/12345678")
+        engineSession.loadUrl("FIDO:/12345678")
+        verify(geckoSession, never()).load(GeckoSession.Loader().uri("fido:/12345678"))
+        verify(geckoSession, never()).load(GeckoSession.Loader().uri("FIDO:/12345678"))
     }
 
     @Test
diff --git a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
index a03057e069f..a43351739d7 100644
--- a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
+++ b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
@@ -47,6 +47,7 @@ class AppLinksUseCasesTest {
     private val javascriptUrl = "javascript:'hello, world'"
     private val jarUrl = "jar:file://some/path/test.html"
     private val contentUrl = "content://media/external_primary/downloads/12345"
+    private val fidoPath = "fido:12345678"
     private val fileType = "audio/mpeg"
     private val layerUrl = "https://example.com"
     private val layerPackage = "com.example.app"
@@ -217,6 +218,15 @@ class AppLinksUseCasesTest {
         assertFalse(redirect.isRedirect())
     }
 
+    @Test
+    fun `A fido url is not an app link`() {
+        val context = createContext(Triple(fidoPath, appPackage, ""))
+        val subject = AppLinksUseCases(context, { true })
+
+        val redirect = subject.interceptedAppLinkRedirect(fidoPath)
+        assertFalse(redirect.isRedirect())
+    }
+
     @Test
     fun `Will not redirect app link if browser option set to false and scheme is supported`() {
         val context = createContext(Triple(appUrl, appPackage, ""))
diff --git a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
index 24315ff585c..6f9d0b405e1 100644
--- a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
+++ b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
@@ -63,4 +63,10 @@ public class IntentUtilsTest {
     final String uri = "intent:non_scheme_intent#Intent;end";
     assertTrue(IntentUtils.isUriSafeForScheme(uri));
   }
+
+  @Test
+  public void unsafeFidoUri() {
+    final String uri = "fido:/12345678";
+    assertFalse(IntentUtils.isUriSafeForScheme(uri));
+  }
 }
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/GeckoEngineSessionTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
index f5b121169d4..ef7087a4cdd 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
@@ -637,6 +637,11 @@ class GeckoEngineSessionTest {
         engineSession.loadUrl("RESOURCE://package/test.text")
         verify(geckoSession, never()).load(GeckoSession.Loader().uri("resource://package/test.text"))
         verify(geckoSession, never()).load(GeckoSession.Loader().uri("RESOURCE://package/test.text"))
+
+        engineSession.loadUrl("fido:/12345678")
+        engineSession.loadUrl("FIDO:/12345678")
+        verify(geckoSession, never()).load(GeckoSession.Loader().uri("fido:/12345678"))
+        verify(geckoSession, never()).load(GeckoSession.Loader().uri("FIDO:/12345678"))
     }
 
     @Test
diff --git a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
index a03057e069f..a43351739d7 100644
--- a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
+++ b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
@@ -47,6 +47,7 @@ class AppLinksUseCasesTest {
     private val javascriptUrl = "javascript:'hello, world'"
     private val jarUrl = "jar:file://some/path/test.html"
     private val contentUrl = "content://media/external_primary/downloads/12345"
+    private val fidoPath = "fido:12345678"
     private val fileType = "audio/mpeg"
     private val layerUrl = "https://example.com"
     private val layerPackage = "com.example.app"
@@ -217,6 +218,15 @@ class AppLinksUseCasesTest {
         assertFalse(redirect.isRedirect())
     }
 
+    @Test
+    fun `A fido url is not an app link`() {
+        val context = createContext(Triple(fidoPath, appPackage, ""))
+        val subject = AppLinksUseCases(context, { true })
+
+        val redirect = subject.interceptedAppLinkRedirect(fidoPath)
+        assertFalse(redirect.isRedirect())
+    }
+
     @Test
     fun `Will not redirect app link if browser option set to false and scheme is supported`() {
         val context = createContext(Triple(appUrl, appPackage, ""))
diff --git a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
index 24315ff585c..6f9d0b405e1 100644
--- a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
+++ b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
@@ -63,4 +63,10 @@ public class IntentUtilsTest {
     final String uri = "intent:non_scheme_intent#Intent;end";
     assertTrue(IntentUtils.isUriSafeForScheme(uri));
   }
+
+  @Test
+  public void unsafeFidoUri() {
+    final String uri = "fido:/12345678";
+    assertFalse(IntentUtils.isUriSafeForScheme(uri));
+  }
 }
Loading diff…