Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in WebAppInstalls
DescriptionMissing authorization in WebAppInstalls
ComponentWebAppInstalls
Bug ClassLogic Error
Tracker497095313
Fix commit012d1f609b9c (chromium/src) +95/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
content/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java
modified

Files Changed

  • chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java
  • chrome/android/junit/BUILD.gn
  • chrome/android/junit/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.java
  • content/browser/renderer_host/render_frame_host_android.cc
  • content/browser/renderer_host/render_frame_host_android.h
  • content/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java
  • content/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.java
From 012d1f609b9cd6e55e83b139474c8c3ed9999091 Mon Sep 17 00:00:00 2001
From: Nate Chapin <[email protected]>
Date: Fri, 17 Jul 2026 08:51:14 -0700
Subject: [PATCH] [Android] Only bind InstalledAppProvider for primary main frame

InstalledAppProviderFactory now returns null and terminates the
renderer when the requesting RenderFrameHost is not the primary
main frame, matching the behaviour of the C++
InstalledAppProviderImpl::Create binder used on other platforms.

Exposes RenderFrameHost::IsInPrimaryMainFrame to JNI to support this.

Adds a Robolectric unit test for the factory.

TAG=agy
CONV=4b21b69b-2602-49f6-934c-1f7b9d1fcd9c

Fixed: 497095313
Change-Id: Id68557a96d18ab377cd2c0dbc73ea96dd9065132
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8109878
Commit-Queue: Nate Chapin <[email protected]>
Reviewed-by: Bo Liu <[email protected]>
Reviewed-by: Peter Conn <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1663928}
---

diff --git a/chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java b/chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java
index e4c8370..2043f59c 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java
@@ -19,12 +19,20 @@
         implements InterfaceFactory<@Nullable InstalledAppProvider> {
     private final RenderFrameHost mRenderFrameHost;
 
+    // BadMessageReason::RFH_INVALID_CALL_FROM_NOT_MAIN_FRAME in
+    // content/browser/bad_message.h
+    private static final int RFH_INVALID_CALL_FROM_NOT_MAIN_FRAME = 227;
+
     public InstalledAppProviderFactory(RenderFrameHost renderFrameHost) {
         mRenderFrameHost = renderFrameHost;
     }
 
     @Override
-    public InstalledAppProvider createImpl() {
+    public @Nullable InstalledAppProvider createImpl() {
+        if (!mRenderFrameHost.isInPrimaryMainFrame()) {
+            mRenderFrameHost.terminateRendererDueToBadMessage(RFH_INVALID_CALL_FROM_NOT_MAIN_FRAME);
+            return null;
+        }
         Profile profile =
                 Profile.fromWebContents(WebContentsStatics.fromRenderFrameHost(mRenderFrameHost));
         assert profile != null;
diff --git a/chrome/android/junit/BUILD.gn b/chrome/android/junit/BUILD.gn
index 9304ae7..3560811 100644
--- a/chrome/android/junit/BUILD.gn
+++ b/chrome/android/junit/BUILD.gn
@@ -1594,6 +1594,7 @@
       "src/org/chromium/chrome/browser/incognito/IncognitoWindowNightModeStateProviderUnitTest.java",
       "src/org/chromium/chrome/browser/init/ActivityProfileProviderTest.java",
       "src/org/chromium/chrome/browser/init/AsyncInitTaskRunnerTest.java",
+      "src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.java",
       "src/org/chromium/chrome/browser/invalidation/ResumableDelayedTaskRunnerTest.java",
       "src/org/chromium/chrome/browser/invalidation/SessionsInvalidationManagerTest.java",
       "src/org/chromium/chrome/browser/messages/ChromeMessageAutodismissDurationProviderTest.java",
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.java
new file mode 100644
index 0000000..3132dfc
--- /dev/null
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.java
@@ -0,0 +1,61 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package org.chromium.chrome.browser.installedapp;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.verify;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.annotation.Config;
+
+import org.chromium.base.test.BaseRobolectricTestRunner;
+import org.chromium.chrome.browser.profiles.Profile;
+import org.chromium.content_public.browser.RenderFrameHost;
+import org.chromium.content_public.browser.WebContents;
+import org.chromium.content_public.browser.WebContentsStatics;
+import org.chromium.installedapp.mojom.InstalledAppProvider;
+
+/** Unit tests for {@link InstalledAppProviderFactory}. */
+@RunWith(BaseRobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class InstalledAppProviderFactoryTest {
+    @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+
+    @Mock private RenderFrameHost mRenderFrameHost;
+    @Mock private WebContents mWebContents;
+    @Mock private Profile mProfile;
+    private static final int RFH_INVALID_CALL_FROM_NOT_MAIN_FRAME = 227;
+
+    @Before
+    public void setUp() {
+        WebContentsStatics.setWebContentsForTesting(mWebContents);
+        Profile.setProfileFromWebContentsForTesting(mProfile);
+    }
+
+    @Test
+    public void testCreateImpl_primaryMainFrame() {
+        doReturn(true).when(mRenderFrameHost).isInPrimaryMainFrame();
+        InstalledAppProviderFactory factory = new InstalledAppProviderFactory(mRenderFrameHost);
+        InstalledAppProvider provider = factory.createImpl();
+        assertNotNull(provider);
+    }
+
+    @Test
+    public void testCreateImpl_notPrimaryMainFrame() {
+        doReturn(false).when(mRenderFrameHost).isInPrimaryMainFrame();
+        InstalledAppProviderFactory factory = new InstalledAppProviderFactory(mRenderFrameHost);
+        InstalledAppProvider provider = factory.createImpl();
+        assertNull(provider);
+        verify(mRenderFrameHost).terminateRendererDueToBadMessage(RFH_INVALID_CALL_FROM_NOT_MAIN_FRAME);
+    }
+}
diff --git a/content/browser/renderer_host/render_frame_host_android.cc b/content/browser/renderer_host/render_frame_host_android.cc
index 43d91b8..d6625f5 100644
--- a/content/browser/renderer_host/render_frame_host_android.cc
+++ b/content/browser/renderer_host/render_frame_host_android.cc
@@ -125,6 +125,10 @@
   return render_frame_host_->GetMainFrame()->GetJavaRenderFrameHost();
 }
 
+bool RenderFrameHostAndroid::IsInPrimaryMainFrame(JNIEnv* env) const {
+  return render_frame_host_->IsInPrimaryMainFrame();
+}
+
 void RenderFrameHostAndroid::GetCanonicalUrlForSharing(
     base::OnceCallback<void(const std::optional<GURL>&)> callback) const {
   render_frame_host_->GetCanonicalUrl(std::move(callback));
diff --git a/content/browser/renderer_host/render_frame_host_android.h b/content/browser/renderer_host/render_frame_host_android.h
index 93e7fbc..7fc816a1 100644
--- a/content/browser/renderer_host/render_frame_host_android.h
+++ b/content/browser/renderer_host/render_frame_host_android.h
@@ -46,6 +46,8 @@
 
   base::android::ScopedJavaLocalRef<jobject> GetMainFrame(JNIEnv* env);
 
+  bool IsInPrimaryMainFrame(JNIEnv* env) const;
+
   void GetCanonicalUrlForSharing(
       base::OnceCallback<void(const std::optional<GURL>&)> callback) const;
 
diff --git a/content/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java b/content/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java
index ac7b19a0..bfc6170 100644
--- a/content/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java
+++ b/content/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java
@@ -113,6 +113,12 @@
     }
 
     @Override
+    public boolean isInPrimaryMainFrame() {
+        if (mNativeRenderFrameHostAndroid == 0) return false;
+        return RenderFrameHostImplJni.get().isInPrimaryMainFrame(mNativeRenderFrameHostAndroid);
+    }
+
+    @Override
     public void getCanonicalUrlForSharing(Callback<@Nullable GURL> callback) {
         if (mNativeRenderFrameHostAndroid == 0) {
             callback.onResult(null);
@@ -337,6 +343,8 @@
 
         RenderFrameHost getMainFrame(long nativeRenderFrameHostAndroid);
 
+        boolean isInPrimaryMainFrame(long nativeRenderFrameHostAndroid);
+
         void getCanonicalUrlForSharing(
                 long nativeRenderFrameHostAndroid,
                 @JniType("base::OnceCallback<void(const std::optional<GURL>&)>")
diff --git a/content/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.java b/content/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.java
index 3ae9407..0b0cadcc 100644
--- a/content/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.java
+++ b/content/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.java
@@ -72,6 +72,11 @@
     @Nullable RenderFrameHost getMainFrame();
 
     /**
+     * @return Whether the frame is the primary main frame.
+     */
+    boolean isInPrimaryMainFrame();
+
+    /**
      * Fetch the canonical URL associated with the fame.
      *
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/public/test/android/javatests/src/org/chromium/content_public/browser/test/mock/MockRenderFrameHost.java b/content/public/test/android/javatests/src/org/chromium/content_public/browser/test/mock/MockRenderFrameHost.java
index 061c1a0..d9f7d13 100644
--- a/content/public/test/android/javatests/src/org/chromium/content_public/browser/test/mock/MockRenderFrameHost.java
+++ b/content/public/test/android/javatests/src/org/chromium/content_public/browser/test/mock/MockRenderFrameHost.java
@@ -38,6 +38,11 @@
     }
 
     @Override
+    public boolean isInPrimaryMainFrame() {
+        return false;
+    }
+
+    @Override
     public void getCanonicalUrlForSharing(Callback<GURL> callback) {}
 
     @Override
Loading diff…

Original Bug Report

reported by [email protected]

Potential Sandbox Escape: Missing OutermostMainFrame Check in Android InstalledAppProvider

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: The InstalledAppProvider Mojo interface lacks an outermost main frame check on Android. A compromised subframe renderer can bind this interface, bypassing renderer-side checks, to silently query the user’s installed WebAPKs or trigger limited SSRF requests from the browser process.

Affected files:

  • chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java
  • chrome/browser/chrome_browser_interface_binders.cc
  • components/installedapp/android/java/src/org/chromium/components/installedapp/InstalledAppProviderImpl.java

Estimated timestamp from git blame: 2025-07-02

Overview

The InstalledAppProvider interface allows websites to check if their related applications are installed on the user’s device. For security and privacy reasons, this API is restricted to the outermost main frame. This restriction is enforced in the renderer (navigator_installed_app.cc).

On non-Android platforms, InstalledAppProviderImpl::Create performs this verification using host.GetParentOrOuterDocument(). If the frame is not an outermost main frame, the browser kills the renderer with a ReportBadMessage call.

However, on Android, the InstalledAppProvider interface is bound through a different path. In chrome_browser_interface_binders.cc, the binder uses ForwardToJavaFrame, which lacks any frame-type validation. The request is then forwarded to InstalledAppProviderFactory.java, which also does not verify if the frame is the outermost main frame before creating the implementation.

A compromised renderer in a cross-origin subframe can bypass the renderer-side check and bind the InstalledAppProvider interface directly. Once bound, the Android implementation (InstalledAppProviderImpl.java) provides several oracles that can be exploited:

  1. Cross-Origin WebAPK Enumeration: The implementation trusts the manifestUrl provided by the renderer in the FilterInstalledApps Mojo call. If the renderer provides a manifestUrl that matches the url of a RelatedApplication, the implementation assumes the site is checking for its own WebAPK and bypasses the Digital Asset Links (DAL) relationship check. A compromised renderer can set both to an arbitrary target URL (e.g., https://bank.example/manifest.json) and determine if the user has that specific WebAPK installed. The underlying implementation in WebApkValidator.java scans all installed packages on the device to find a match without consulting the actual frame origin.

  2. Browser-Process Network Fetch (Limited SSRF): If the renderer provides mismatched URLs, the implementation triggers a browser-process fetch of .well-known/assetlinks.json from the origin of the app.url. This allows a compromised renderer to initiate network requests from the browser process to arbitrary origins, although the path is fixed and credentials are omitted.

  3. Subframe Play App Oracle: The implementation allows subframes to query for their own associated Play Store apps, which is contrary to the API’s top-level-only restriction.

Potential Attacker Steps

Note: These steps are based on static code analysis. A working proof of concept has not yet been developed.

  1. An attacker gains arbitrary code execution in a cross-origin subframe renderer (e.g., via a v8 exploit).
  2. Using MojoJS or direct Mojo bindings, the compromised subframe requests to bind the blink::mojom::InstalledAppProvider interface.
  3. The browser process blindly binds the interface via ForwardToJavaFrame on Android.
  4. For WebAPK Enumeration: The attacker sends a FilterInstalledApps message, providing a target WebAPK URL (e.g., https://target.app/manifest.json) for both the manifestUrl parameter and the RelatedApplication’s url parameter (with platform set to "webapp"). The browser responds with the installation status, bypassing DAL checks.
  5. For SSRF: The attacker sends a FilterInstalledApps message where app.url is set to an arbitrary origin and differs from manifestUrl. The browser process initiates a request to [app.url]/.well-known/assetlinks.json.

Suggested Fix

Add an outermost main frame check to InstalledAppProviderFactory.java or when registering the binder in chrome_browser_interface_binders.cc. If the provided RenderFrameHost is not the outermost main frame, the binding request should be rejected (e.g., by returning null or terminating the renderer).

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker