CVE-2026-106366
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java |
modified |
Files Changed
chrome/android/junit/src/org/chromium/chrome/browser/browserservices/PostMessageHandlerTest.javachrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java
Patch
From 25e96effb670a7162d93e978e20f8595a151a42b Mon Sep 17 00:00:00 2001 From: Jinsuk Kim <[email protected]> Date: Tue, 15 Sep 2026 11:52:59 -0700 Subject: [PATCH] [CCT] Invalidate PostMessage channel and observer on WebContents swap When CustomTabActivity reports a tab swap, PostMessageHandler.reset() replaces the associated WebContents. Previously, the MessagePort channel and WebContentsObserver created for the previous WebContents were not invalidated or detached upon reset. This allowed postMessage communication from a retained page to be misattributed to the newly swapped WebContents origin. This CL ensures that when reset() is called with a new or destroyed/null WebContents by closing and disconnecting any active MessagePort channel. TAG=agy CONV=5011f4d0-94e4-4dbd-b5a5-199e84bfe726 Bug: 552115620 Change-Id: I12f3fca27507510c17e2713892e1b15e86d0a359 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8332171 Commit-Queue: Jinsuk Kim <[email protected]> Reviewed-by: Peter Conn <[email protected]> Cr-Commit-Position: refs/heads/main@{#1698274} --- 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 e8e36511..8a451d6 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 @@ -118,11 +118,7 @@ // 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; - } + closeChannelAndForgetWebContents(); } mWebContents = webContents; 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 79763f8..53c1ddd7 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 @@ -85,6 +85,7 @@ @Before public void setUp() { lenient().when(mWebContents.getMainFrame()).thenReturn(mMainFrame); + lenient().when(mSecondWebContents.getMainFrame()).thenReturn(mMainFrame); lenient() .when(mMainFrame.getLastCommittedURL()) .thenReturn(new GURL("https://www.example.com/page")); @@ -94,7 +95,7 @@ .thenReturn(new MessagePort[] {mSecondLocalPort, mSecondRemotePort}); lenient() .when(mSecondWebContents.createMessageChannel()) - .thenReturn(new MessagePort[] {mFirstLocalPort, mFirstRemotePort}); + .thenReturn(new MessagePort[] {mSecondLocalPort, mSecondRemotePort}); lenient().when(mNavigation.hasCommitted()).thenReturn(true); lenient().when(mNavigation.isSameDocument()).thenReturn(false); GURLUtilsJni.setInstanceForTesting(mGURLUtilsJni); @@ -315,6 +316,9 @@ verify(mWebContents).createMessageChannel(); verify(mPostMessageBackend).onNotifyMessageChannelReady(null); + assertEquals( + CustomTabsService.RESULT_SUCCESS, + mHandler.postMessageFromClientApp("message after document loaded")); } @Test @@ -635,6 +639,106 @@ verify(mFirstLocalPort, never()).postMessage(any(), any()); } + @Test + public void testResetWithNewWebContentsClosesPreviousChannelAndDetachesObserver() { + WebContentsObserver observer = resetAndFinishInitialNavigation(); + mHandler.initializeWithPostMessageUri(SOURCE_URI, TARGET_URI); + verify(mPostMessageBackend).onNotifyMessageChannelReady(null); + + mHandler.reset(mSecondWebContents); + + verify(mFirstLocalPort).close(); + verify(mPostMessageBackend).onDisconnectChannel(any()); + verify((WebContentsObserver.Observable) mWebContents).removeObserver(observer); + verify((WebContentsObserver.Observable) mSecondWebContents).addObserver(any()); + assertNull(mHandler.getPostMessageUriForTesting()); + assertNull(mHandler.getPostMessageTargetUriForTesting()); + assertEquals( + CustomTabsService.RESULT_FAILURE_MESSAGING_ERROR, + mHandler.postMessageFromClientApp("message to swapped web contents")); + + mHandler.initializeWithPostMessageUri(SOURCE_URI, TARGET_URI); + verify(mSecondWebContents).createMessageChannel(); + assertEquals( + CustomTabsService.RESULT_SUCCESS, + mHandler.postMessageFromClientApp("message to second web contents")); + } + + @Test + public void testResetWithNullClosesChannelAndDetachesObserver() { + WebContentsObserver observer = resetAndFinishInitialNavigation(); + mHandler.initializeWithPostMessageUri(SOURCE_URI, TARGET_URI); + verify(mPostMessageBackend).onNotifyMessageChannelReady(null); + + mHandler.reset(null); + + verify(mFirstLocalPort).close(); + verify(mPostMessageBackend).onDisconnectChannel(any()); + verify((WebContentsObserver.Observable) mWebContents).removeObserver(observer); + assertNull(mHandler.getPostMessageUriForTesting()); + assertNull(mHandler.getPostMessageTargetUriForTesting()); + assertEquals( + CustomTabsService.RESULT_FAILURE_MESSAGING_ERROR, + mHandler.postMessageFromClientApp("message after null reset")); + } + + @Test + public void testResetWithDestroyedWebContentsClosesChannelAndDetachesObserver() { + WebContentsObserver observer = resetAndFinishInitialNavigation(); + mHandler.initializeWithPostMessageUri(SOURCE_URI, TARGET_URI); + verify(mPostMessageBackend).onNotifyMessageChannelReady(null); + + WebContents destroyedWebContents = org.mockito.Mockito.mock(WebContents.class); + when(destroyedWebContents.isDestroyed()).thenReturn(true); + + mHandler.reset(destroyedWebContents); + + verify(mFirstLocalPort).close(); + verify(mPostMessageBackend).onDisconnectChannel(any()); + verify((WebContentsObserver.Observable) mWebContents).removeObserver(observer); + assertNull(mHandler.getPostMessageUriForTesting()); + assertNull(mHandler.getPostMessageTargetUriForTesting()); + assertEquals( + CustomTabsService.RESULT_FAILURE_MESSAGING_ERROR, + mHandler.postMessageFromClientApp("message after destroyed reset")); + } + + @Test + public void testWebContentsDestroyedEventClosesChannelAndDetachesObserver() { + WebContentsObserver observer = resetAndFinishInitialNavigation(); + mHandler.initializeWithPostMessageUri(SOURCE_URI, TARGET_URI); + verify(mPostMessageBackend).onNotifyMessageChannelReady(null); + + observer.webContentsDestroyed(); + + verify(mFirstLocalPort).close(); + verify(mPostMessageBackend).onDisconnectChannel(any()); + verify((WebContentsObserver.Observable) mWebContents).removeObserver(observer); + assertNull(mHandler.getPostMessageUriForTesting()); + assertNull(mHandler.getPostMessageTargetUriForTesting()); + assertEquals( + CustomTabsService.RESULT_FAILURE_MESSAGING_ERROR, + mHandler.postMessageFromClientApp("message after webContentsDestroyed")); + } + + @Test + public void testPrimaryMainFrameRenderProcessGoneClosesChannelAndDetachesObserver() { + WebContentsObserver observer = resetAndFinishInitialNavigation(); + mHandler.initializeWithPostMessageUri(SOURCE_URI, TARGET_URI); + verify(mPostMessageBackend).onNotifyMessageChannelReady(null); + + observer.primaryMainFrameRenderProcessGone(TerminationStatus.PROCESS_WAS_KILLED); + + verify(mFirstLocalPort).close(); + verify(mPostMessageBackend).onDisconnectChannel(any()); + verify((WebContentsObserver.Observable) mWebContents).removeObserver(observer); + assertNull(mHandler.getPostMessageUriForTesting()); + assertNull(mHandler.getPostMessageTargetUriForTesting()); + assertEquals( + CustomTabsService.RESULT_FAILURE_MESSAGING_ERROR, + mHandler.postMessageFromClientApp("message after render process gone")); + } + /** Builds an opaque {@link org.chromium.url.Origin}, as a sandboxed frame would have. */ private static org.chromium.url.Origin createOpaqueOrigin() { org.chromium.url.internal.mojom.Origin mojom = new org.chromium.url.internal.mojom.Origin();
Original Bug Report
Custom Tabs postMessage origin confusion after a WebContents swap allows cross-origin impersonation
Report description
Custom Tabs postMessage origin confusion after a WebContents swap allows cross-origin impersonation
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
Chrome Custom Tabs keeps a postMessage MessagePort connected to the previous page after the active tab is swapped to a different WebContents. After the swap, messages still originate from the old page, but Chrome attributes them to the origin of the new foreground page.
The issue occurs as follows:
-
A Custom Tabs client establishes a postMessage channel with page A.
-
Page A receives the transferred
MessagePort. -
Page A opens cross-origin page B using
target="_blank", causing the Custom Tab to swap to B’sWebContents. -
Page A remains alive and sends a message through its existing
MessagePort. -
Chrome delivers A’s message to the Android client, but sets:
androidx.browser.customtabs.POST_MESSAGE_ORIGIN = https://B.example -
If the client replies with
CustomTabsSession.postMessage(), the reply is delivered through the stale channel to page A, not to page B.
The resulting internal state is:
mWebContents -> page B
mChannel -> page A
The root cause is in:
chrome/android/java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java
When CustomTabActivity reports a tab swap, PostMessageHandler.reset(newWebContents) replaces mWebContents without closing the MessagePort channel created for the previous WebContents:
public void reset(final @Nullable WebContents webContents) {
if (webContents == null || webContents.isDestroyed()) {
closeChannelAndForgetWebContents();
return;
}
if (webContents.equals(mWebContents)) return;
mWebContents = webContents;
// ...
}
mChannel is not closed or cleared before mWebContents is replaced.
The MessagePort callback is shared across resets. When a message arrives through the stale channel, it calculates the reported origin from the mutable mWebContents rather than from the WebContents or document that owns the channel:
GURL url = mWebContents.getMainFrame().getLastCommittedURL();
String origin = GURLUtils.getOrigin(url.getSpec());
Bundle bundle = new Bundle();
bundle.putString(POST_MESSAGE_ORIGIN, origin);
mPostMessageBackend.onPostMessage(
messagePayload.getAsString(), bundle);
After the swap, mWebContents refers to B while the callback was invoked by mChannel, which remains connected to A. Chrome consequently combines A’s message body with B’s origin.
The reverse direction has the same identity mismatch. postMessageFromClientApp() sends through mChannel[0], so client-to-page messages continue to reach A even though mWebContents and POST_MESSAGE_ORIGIN identify B.
Normal cross-document navigation in the same WebContents closes the channel from WebContentsObserver.didFinishNavigationInPrimaryMainFrame(). A WebContents swap follows a different path: reset() changes mWebContents without invalidating the channel associated with the previous WebContents.
I reproduced both directions end to end on an unmodified physical Android device running Chrome Stable 151.0.7922.171 and Android 16:
Before the swap:
message body: FROM_A_BEFORE_SWAP
POST_MESSAGE_ORIGIN: https://A.example/
After A opens B and the WebContents is swapped:
message body: FROM_OLD_A_AFTER_SWAP
POST_MESSAGE_ORIGIN: https://B.example/
CustomTabsSession.postMessage(...) returned RESULT_SUCCESS,
and page A received the submitted payload.
Page B never received the MessagePort.
This confirms that the message payload and the origin reported to the client refer to different documents: the payload is sent through page A’s retained MessagePort, while the reported origin is derived from page B’s current WebContents. Messages sent by the client continue to use page A’s retained channel.
No Chrome flags or browser modification were used.
Impact analysis
This issue allows a page from origin A to impersonate origin B to the Android client of a Custom Tabs session.
The Android client does not receive a MessagePort or document reference that it can independently inspect to determine which page sent a message. Chrome supplies the message body together with androidx.browser.customtabs.POST_MESSAGE_ORIGIN. After the WebContents swap, that browser-supplied identity is B even though the message was sent by A. The client therefore cannot distinguish the forged message from a message that Chrome attributes to B.
This crosses an origin-based authorization boundary in clients that expose different native operations to different web origins. For example, a client may reject a native RPC request from an untrusted or low-privilege origin A while allowing the same request from trusted origin B. After opening B and triggering the swap, A can submit that request through its retained port. Chrome labels it as originating from B, causing the client to apply B’s authorization policy to A’s input.
The stale channel is bidirectional. If the authorized operation returns a capability, token, account data, or other response intended for B, CustomTabsSession.postMessage() sends that response through the retained channel to A. The issue can therefore result in both unauthorized native actions and disclosure of data restricted to B.
The attack does not require control of B, script execution in B, a same-origin-policy bypass inside B, renderer compromise, or a second vulnerability. The attacker only needs control of page A and the ability to make A open B in the same Custom Tabs flow. Page B does not send the forged request and never receives the transferred port; its current foreground WebContents is used only to supply the incorrect origin attached by Chrome.
This is not merely a stale UI or incorrect diagnostic value. Chrome combines the authority of one security principal with a communication endpoint owned by another:
request payload and response channel: origin A
origin asserted to the Android client: origin B
Consequently, any security decision made from the sender origin supplied by Chrome is applied to the wrong web principal.
The cause
What version of Chrome have you found the security issue in?
[151.0.7922.171] [stable]
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Android issue
How would you like to be publicly acknowledged for your report?
Naoya Miyaguchi