Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in CustomTabs
DescriptionRace condition in CustomTabs
ComponentCustomTabs
Sub-componentUI>Browser>Mobile>CustomTabs
Bug ClassRace
Tracker517150523
Fix commitfe9db40e7667 (chromium/src) +715/-47
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
chrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java
modified

Files Changed

  • chrome/android/junit/src/org/chromium/chrome/browser/browserservices/PostMessageHandlerTest.java
  • chrome/android/junit/BUILD.gn
  • chrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java
From fe9db40e76673d7a9fa3a7f66fcbb9b34844edec Mon Sep 17 00:00:00 2001
From: Mohamed Adel <adelm@google.com>
Date: Fri, 11 Sep 2026 12:35:09 -0700
Subject: [PATCH] Improve lifecycle and navigation handling in PostMessageHandler

Update PostMessageHandler to properly track and clean up its
WebContentsObserver during resets and WebContents destruction. Ensure
that closeChannelAndForgetWebContents() cleans up cached state
regardless of whether a message channel was previously active.

Additionally, ensure navigation state advancement only occurs for
committed navigations, reset target URIs alongside source URIs on
cross-document navigations, and validate committed frame URLs before
extracting message origin metadata.

1. Defaulting the target origin to the verified origin: in
   onOriginVerified(), if mPostMessageTargetUri is not set it now
   defaults to origin.uri() rather than remaining null/wildcard.

2. Target origin matching on initialization: initializeWithWebContents()
   now validates that the active main frame's committed origin matches
   the target origin, via isTargetOriginMatchingWebContents(), before
   establishing the message channel.

TAG=agy

Fixed: 517150523
Change-Id: I7d1fc59133f85367c7f6b80ffe5786a5d952ef65
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8391560
Commit-Queue: Daniel Murphy <dmurph@chromium.org>
Reviewed-by: Daniel Murphy <dmurph@chromium.org>
Auto-Submit: Moe Adel <adelm@google.com>
Cr-Commit-Position: refs/heads/main@{#1696513}
---
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java
index 4d27daace..e8e36511 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java
@@ -32,6 +32,7 @@
 import org.chromium.content_public.browser.MessagePort.MessageCallback;
 import org.chromium.content_public.browser.NavigationHandle;
 import org.chromium.content_public.browser.Page;
+import org.chromium.content_public.browser.RenderFrameHost;
 import org.chromium.content_public.browser.WebContents;
 import org.chromium.content_public.browser.WebContentsObserver;
 import org.chromium.net.GURLUtils;
@@ -51,7 +52,16 @@
     private final MessageCallback mMessageCallback;
     private final PostMessageBackend mPostMessageBackend;
     private @Nullable WebContents mWebContents;
+    private @Nullable WebContentsObserver mWebContentsObserver;
     private MessagePort @Nullable [] mChannel;
+
+    /**
+     * The origin of the document {@link #mChannel} was created for, captured at channel creation
+     * time. Resolving the origin lazily per message would be racy: a message can sit in the UI task
+     * queue while a navigation commits, which would attribute it to the wrong document.
+     */
+    private @Nullable String mChannelOrigin;
+
     private @Nullable Uri mPostMessageSourceUri;
     private @Nullable Uri mPostMessageTargetUri;
 
@@ -84,11 +94,9 @@
                     }
 
                     Bundle bundle = null;
-                    GURL url = mWebContents.getMainFrame().getLastCommittedURL();
-                    if (url != null) {
-                        String origin = GURLUtils.getOrigin(url.getSpec());
+                    if (mChannelOrigin != null && !mChannelOrigin.isEmpty()) {
                         bundle = new Bundle();
-                        bundle.putString(POST_MESSAGE_ORIGIN, origin);
+                        bundle.putString(POST_MESSAGE_ORIGIN, mChannelOrigin);
                     }
                     assumeNonNull(messagePayload.getAsString());
                     mPostMessageBackend.onPostMessage(messagePayload.getAsString(), bundle);
@@ -109,42 +117,107 @@
         }
         // Can't reset with the same web contents twice.
         if (webContents.equals(mWebContents)) return;
+        if (mWebContents != null) {
+            closeChannel();
+            if (mWebContentsObserver != null) {
+                mWebContentsObserver.observe(null);
+                mWebContentsObserver = null;
+            }
+        }
         mWebContents = webContents;
-        new WebContentsObserver(webContents) {
-            private boolean mNavigatedOnce;
+        mWebContentsObserver =
+                new WebContentsObserver(webContents) {
+                    // A reset can attach to a WebContents that has already committed a document,
+                    // for example when a hidden tab is promoted or a tab is swapped in. Starting
+                    // from false there would misread the next genuine cross-document navigation as
+                    // the initial one and leave the channel alive across the document change.
+                    private boolean mNavigatedOnce =
+                            !GURL.isEmptyOrInvalid(webContents.getLastCommittedUrl());
 
-            @Override
-            public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigation) {
-                if (mNavigatedOnce && navigation.hasCommitted() && !navigation.isSameDocument()) {
-                    mPostMessageSourceUri = null;
-                    closeChannel();
-                    return;
-                }
-                mNavigatedOnce = true;
-            }
+                    @Override
+                    public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigation) {
+                        if (mNavigatedOnce
+                                && navigation.hasCommitted()
+                                && !navigation.isSameDocument()) {
+                            mPostMessageSourceUri = null;
+                            mPostMessageTargetUri = null;
+                            closeChannel();
+                            return;
+                        }
+                        if (navigation.hasCommitted()) {
+                            mNavigatedOnce = true;
+                        }
+                    }
 
-            @Override
-            public void primaryMainFrameRenderProcessGone(
-                    @TerminationStatus int terminationStatus) {
-                closeChannelAndForgetWebContents();
-            }
+                    @Override
+                    public void primaryMainFrameRenderProcessGone(
+                            @TerminationStatus int terminationStatus) {
+                        closeChannelAndForgetWebContents();
+                    }
 
-            @Override
-            public void documentLoadedInPrimaryMainFrame(
-                    Page page,
-                    GlobalRenderFrameHostId rfhId,
-                    @LifecycleState int rfhLifecycleState) {
-                if (mChannel != null || mPostMessageSourceUri == null) {
-                    return;
-                }
-                initializeWithWebContents(webContents);
-            }
-        };
+                    @Override
+                    public void webContentsDestroyed() {
+                        closeChannelAndForgetWebContents();
+                    }
+
+                    @Override
+                    public void documentLoadedInPrimaryMainFrame(
+                            Page page,
+                            GlobalRenderFrameHostId rfhId,
+                            @LifecycleState int rfhLifecycleState) {
+                        if (mChannel != null || mPostMessageSourceUri == null) {
+                            return;
+                        }
+                        initializeWithWebContents(webContents);
+                    }
+                };
+    }
+
+    private boolean isTargetOriginMatchingWebContents(WebContents webContents) {
+        String target = mPostMessageTargetUri == null ? "" : mPostMessageTargetUri.toString();
+        // An empty or "*" target origin is a wildcard, as understood by
+        // WebContents#postMessageToMainFrame and the postMessage specification, so there is
+        // nothing to match against.
+        if (target.isEmpty() || "*".equals(target)) {
+            return true;
+        }
+        RenderFrameHost mainFrame = webContents.getMainFrame();
+        if (mainFrame == null) {
+            return false;
+        }
+        // A sandboxed frame has an opaque security context even though its URL still looks like a
+        // regular https:// URL, so the URL comparison below would wrongly match. Reject those
+        // outright rather than handing a port to a document that cannot be attributed to an
+        // origin.
+        org.chromium.url.Origin committedOrigin = mainFrame.getLastCommittedOrigin();
+        if (committedOrigin != null && committedOrigin.isOpaque()) {
+            return false;
+        }
+        GURL url = mainFrame.getLastCommittedURL();
+        if (url == null || url.isEmpty() || !url.isValid()) {
+            return false;
+        }
+        Origin currentOrigin = Origin.create(url.getSpec());
+        Origin targetOrigin = Origin.create(mPostMessageTargetUri);
+        if (currentOrigin == null || targetOrigin == null) {
+            return false;
+        }
+        return currentOrigin.equals(targetOrigin);
     }
 
     private void initializeWithWebContents(final WebContents webContents) {
+        if (mPostMessageSourceUri == null || webContents.isDestroyed()) return;
+        if (!isTargetOriginMatchingWebContents(webContents)) {
+            closeChannel();
+            return;
+        }
+        // Replace any existing channel without tearing down the backend: onDisconnectChannel()
+        // unbinds the PostMessageService, which would make the onNotifyMessageChannelReady() call
+        // below a no-op and leave the client waiting forever.
+        closeChannelPort();
         mChannel = webContents.createMessageChannel();
         mChannel[0].setMessageCallback(mMessageCallback, null);
+        mChannelOrigin = getCommittedOrigin(webContents);
 
         assumeNonNull(mPostMessageSourceUri);
         webContents.postMessageToMainFrame(
@@ -157,6 +230,32 @@
     }
 
     /**
+     * @return The origin of the primary main frame's committed document, in the same format
+     *     previously reported to clients via {@link #POST_MESSAGE_ORIGIN}, or null if it cannot be
+     *     determined.
+     */
+    private static @Nullable String getCommittedOrigin(WebContents webContents) {
+        RenderFrameHost mainFrame = webContents.getMainFrame();
+        if (mainFrame == null) return null;
+        GURL url = mainFrame.getLastCommittedURL();
+        if (url == null || url.isEmpty() || !url.isValid()) return null;
+        return GURLUtils.getOrigin(url.getSpec());
+    }
+
+    /**
+     * Drops the message channel without notifying the client. {@link MessagePort#close()} throws if
+     * the port has already been transferred, so guard against that.
+     */
+    private void closeChannelPort() {
+        if (mChannel == null) return;
+        if (!mChannel[0].isClosed() && !mChannel[0].isTransferred()) {
+            mChannel[0].close();
+        }
+        mChannel = null;
+        mChannelOrigin = null;
+    }
+
+    /**
      * Closes the message channel and notifies the client that it is gone, keeping the {@link
      * WebContents}. The client can re-establish messaging for the new document by calling
      * requestPostMessageChannel() again, which re-verifies the origin and re-enters {@link
@@ -164,8 +263,7 @@
      */
     private void closeChannel() {
         if (mChannel == null) return;
-        mChannel[0].close();
-        mChannel = null;
+        closeChannelPort();
         mPostMessageBackend.onDisconnectChannel(ContextUtils.getApplicationContext());
     }
 
@@ -175,12 +273,14 @@
      * channel can be re-established until {@link #reset} supplies a new {@link WebContents}.
      */
     private void closeChannelAndForgetWebContents() {
-        // The reference is only dropped when there was a live channel: keeping a stale
-        // WebContents otherwise is what makes reset() bail out early instead of attaching a
-        // second observer to the same WebContents.
-        if (mChannel == null) return;
         closeChannel();
+        if (mWebContentsObserver != null) {
+            mWebContentsObserver.observe(null);
+            mWebContentsObserver = null;
+        }
         mWebContents = null;
+        mPostMessageSourceUri = null;
+        mPostMessageTargetUri = null;
     }
 
     /**
@@ -188,10 +288,11 @@
      *
      * @param postMessageUri The postMessageUri value to be set.
      */
-    public void initializeWithPostMessageUri(Uri postMessageUri, @Nullable Uri targetOrigin) {
+    public void initializeWithPostMessageUri(
+            @Nullable Uri postMessageUri, @Nullable Uri targetOrigin) {
         mPostMessageSourceUri = postMessageUri;
         mPostMessageTargetUri = targetOrigin;
-        if (mWebContents != null && !mWebContents.isDestroyed()) {
+        if (mPostMessageSourceUri != null && mWebContents != null && !mWebContents.isDestroyed()) {
             initializeWithWebContents(mWebContents);
         }
     }
@@ -216,9 +317,12 @@
         PostTask.postTask(
                 TaskTraits.UI_DEFAULT,
                 () -> {
-                    // It is still possible that the page has navigated while this task is in
-                    // the queue. If that happens fail gracefully.
-                    if (mChannel == null || mChannel[0].isClosed()) return;
+                    // It is still possible that the page has navigated, or that the port has been
+                    // transferred, while this task is in the queue. Both make postMessage() throw,
+                    // so fail gracefully.
+                    if (mChannel == null || mChannel[0].isClosed() || mChannel[0].isTransferred()) {
+                        return;
+                    }
                     mChannel[0].postMessage(new MessagePayload(message), null);
                 });
         return CustomTabsService.RESULT_SUCCESS;
@@ -228,9 +332,10 @@
     public void onOriginVerified(
             String packageName, Origin origin, boolean result, @TriState int online) {
         if (!result) return;
+        Uri targetOrigin = mPostMessageTargetUri != null ? mPostMessageTargetUri : origin.uri();
         initializeWithPostMessageUri(
                 OriginVerifier.getPostMessageUriFromVerifiedOrigin(packageName, origin),
-                mPostMessageTargetUri);
+                targetOrigin);
     }
 
     /**
@@ -239,7 +344,7 @@
      *
      * @param postMessageTargetUri Uri to post the first message to.
      */
-    public void setPostMessageTargetUri(Uri postMessageTargetUri) {
+    public void setPostMessageTargetUri(@Nullable Uri postMessageTargetUri) {
         mPostMessageTargetUri = postMessageTargetUri;
     }
 
@@ -253,4 +358,12 @@
     public @Nullable Uri getPostMessageUriForTesting() {
         return mPostMessageSourceUri;
     }
+
+    public @Nullable WebContents getWebContentsForTesting() {
+        return mWebContents;
+    }
+
+    public @Nullable WebContentsObserver getWebContentsObserverForTesting() {
+        return mWebContentsObserver;
+    }
 }
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/PostMessageHandlerTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/PostMessageHandlerTest.java
index b223173..79763f8 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/PostMessageHandlerTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/PostMessageHandlerTest.java
@@ -5,18 +5,24 @@
 package org.chromium.chrome.browser.browserservices;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import android.net.Uri;
+import android.os.Bundle;
 
 import androidx.browser.customtabs.CustomTabsService;
 import androidx.browser.customtabs.PostMessageBackend;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -26,18 +32,34 @@
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnit;
 import org.mockito.junit.MockitoRule;
+import org.robolectric.shadows.ShadowLooper;
 
+import org.chromium.base.TerminationStatus;
+import org.chromium.base.TriState;
 import org.chromium.base.test.BaseRobolectricTestRunner;
+import org.chromium.components.embedder_support.util.Origin;
+import org.chromium.content_public.browser.GlobalRenderFrameHostId;
+import org.chromium.content_public.browser.LifecycleState;
+import org.chromium.content_public.browser.MessagePayload;
 import org.chromium.content_public.browser.MessagePort;
+import org.chromium.content_public.browser.MessagePort.MessageCallback;
 import org.chromium.content_public.browser.NavigationHandle;
+import org.chromium.content_public.browser.Page;
+import org.chromium.content_public.browser.RenderFrameHost;
 import org.chromium.content_public.browser.WebContents;
 import org.chromium.content_public.browser.WebContentsObserver;
+import org.chromium.mojo_base.mojom.UnguessableToken;
+import org.chromium.net.GURLUtils;
+import org.chromium.net.GURLUtilsJni;
+import org.chromium.url.GURL;
 
 /** Unit tests for {@link PostMessageHandler}. */
 @RunWith(BaseRobolectricTestRunner.class)
 public class PostMessageHandlerTest {
     private static final Uri SOURCE_URI = Uri.parse("android-app://org.chromium.test");
     private static final Uri TARGET_URI = Uri.parse("https://www.example.com");
+    private static final String POST_MESSAGE_ORIGIN =
+            "androidx.browser.customtabs.POST_MESSAGE_ORIGIN";
 
... (file diff truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

PostMessage channel leak in Chrome Custom Tabs on navigation when channel is null

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A lifecycle management flaw in PostMessageHandler allows a malicious Android application to inject a bidirectional MessagePort into an arbitrary website loaded within a Chrome Custom Tab. If a cross-document navigation occurs before the PostMessage channel is fully initialized, the cleanup path is bypassed because mChannel is null, leaking a stale WebContents reference. Once the pending Digital Asset Links (DAL) verification completes, the handler initializes the channel and delivers a wildcard MessagePort to the newly loaded, potentially untrusted origin.

Affected files:

  • chrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java

Estimated timestamp from git blame: 2026-05-18

Issue Overview

In PostMessageHandler, the lifecycle management of mWebContents is potentially flawed when a cross-document navigation occurs before the PostMessage channel has been fully initialized (such as during asynchronous Digital Asset Links (DAL) verification). If mChannel is null when the navigation occurs, the cleanup path is bypassed, leaving a stale reference to mWebContents while the navigation observer self-detaches. A subsequently completed DAL verification will then target the new document on the stale mWebContents, sending a wildcard-target MessagePort to the newly navigated origin.

Root Cause Analysis

In chrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java, the WebContentsObserver is configured to tear down state on cross-document navigation:

@Override
public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigation) {
    if (mNavigatedOnce && navigation.hasCommitted() && !navigation.isSameDocument()) {
        observe(null);                 // Observer detaches itself
        mPostMessageSourceUri = null;
        disconnectChannel();           // Triggers teardown
        return;
    }
    mNavigatedOnce = true;
}

However, the disconnectChannel() method is implemented as follows:

private void disconnectChannel() {
    if (mChannel == null) return;   // Early return if no channel exists
    mChannel[0].close();
    mChannel = null;
    mWebContents = null;            // Bypassed if mChannel is null
    mPostMessageBackend.onDisconnectChannel(ContextUtils.getApplicationContext());
}

If the navigation happens while DAL verification is still pending, mChannel will be null. As a result, disconnectChannel() returns early and mWebContents is not set to null, even though the observer has already detached itself (observe(null)).

When DAL verification eventually completes, onOriginVerified triggers initializeWithPostMessageUri(), which acts on the stale mWebContents without verifying if the current committed origin matches the verified origin:

public void initializeWithPostMessageUri(Uri postMessageUri, @Nullable Uri targetOrigin) {
    mPostMessageSourceUri = postMessageUri;
    mPostMessageTargetUri = targetOrigin;
    if (mWebContents != null && !mWebContents.isDestroyed()) {
        initializeWithWebContents(mWebContents);   // Re-initializes on the stale WebContents
    }
}

Inside initializeWithWebContents(), a new MessageChannel is created and a message is posted to the main frame of the stale WebContents (which now displays the navigated page, such as a third-party victim site):

private void initializeWithWebContents(final WebContents webContents) {
    mChannel = webContents.createMessageChannel();
    mChannel[0].setMessageCallback(mMessageCallback, null);
    webContents.postMessageToMainFrame(
            new MessagePayload(""),
            mPostMessageSourceUri.toString(),
            mPostMessageTargetUri != null ? mPostMessageTargetUri.toString() : "",  // Wildcard target
            new MessagePort[] {mChannel[1]});
    ...
}

Because mPostMessageTargetUri can be null (such as via the 2-argument Custom Tabs API requestPostMessageChannel overload), the target origin defaults to "" (wildcard). Consequently, the browser process delivers the MessagePort to whatever origin is currently loaded in the WebContents without enforcing an origin match.

Potential Attack Steps

(Note: These are suggested/potential steps; our tooling does not currently have the capability to dynamically execute code to verify them.)

  1. A malicious Android application initiates a Chrome Custom Tab (CCT) session.
  2. The application requests a postMessage channel with a target origin parameter set to null.
  3. The application intentionally delays the Digital Asset Links verification (for example, by stalling the .well-known/assetlinks.json fetch on its own server).
  4. The application navigates the CCT to a target victim origin (e.g., https://victim.com).
  5. The navigation completes, triggering the WebContentsObserver to detach and call disconnectChannel(). Since mChannel is null, mWebContents remains pointing to the active WebContents instance.
  6. The application allows the DAL check to succeed. This invokes initializeWithPostMessageUri, delivering a wildcard MessagePort to the victim origin.
  7. If the victim page’s window.onmessage handler does not strictly validate the message’s origin, the attacker can establish a persistent, bidirectional communication channel into the authenticated context of the victim origin.

Suggested Fix

Ensure that mWebContents is always cleared when disconnectChannel() is called, regardless of whether mChannel is null:

private void disconnectChannel() {
    if (mChannel != null) {
        mChannel[0].close();
        mChannel = null;
        mPostMessageBackend.onDisconnectChannel(ContextUtils.getApplicationContext());
    }
    mWebContents = null;
}

Additionally, initializeWithPostMessageUri or initializeWithWebContents should verify that the current committed origin of the WebContents matches the expected target origin before posting the message channel.

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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
Links in the report