Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in WebView
DescriptionInappropriate implementation in WebView
ComponentWebView
Bug ClassLogic Error
Tracker502444677
Fix commitc666e56c8352 (chromium/src) +211/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
android_webview/javatests/src/org/chromium/android_webview/test/WebMessageListenerPrerenderTest.java
modified

Files Changed

  • android_webview/javatests/src/org/chromium/android_webview/test/WebMessageListenerPrerenderTest.java
  • android_webview/test/BUILD.gn
  • android_webview/test/data/prerender-webmessage-index.html
From c666e56c8352ac67b764af8d511da6348d43acd1 Mon Sep 17 00:00:00 2001
From: Peter Pakkenberg <pbirk@chromium.org>
Date: Mon, 20 Apr 2026 04:16:16 -0700
Subject: [PATCH] Fix routing of web messages from prerender documents

Previously, all messages sent to WebView WebMessageListener instances
would be added to the same queue and consumed by whichever listener
happened to be activated first.

This change ensures that messages are routed to the intended target when
a prerendered page activates.

Fixed: 502444677
Test: Added WebMessageListenerPrerenderTest.java
Change-Id: Icb19b2a07d691b1aa9b6559c338882625174e346
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7768332
Auto-Submit: Peter Birk Pakkenberg <pbirk@chromium.org>
Commit-Queue: Peter Birk Pakkenberg <pbirk@chromium.org>
Reviewed-by: Nate Fischer <ntfschr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1617395}
---

diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/WebMessageListenerPrerenderTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/WebMessageListenerPrerenderTest.java
new file mode 100644
index 0000000..38ccdca
--- /dev/null
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/WebMessageListenerPrerenderTest.java
@@ -0,0 +1,147 @@
+// 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.android_webview.test;
+
+import android.annotation.SuppressLint;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.LargeTest;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.UseParametersRunnerFactory;
+
+import org.chromium.android_webview.AwContents;
+import org.chromium.android_webview.settings.SpeculativeLoadingAllowedFlags;
+import org.chromium.base.ThreadUtils;
+import org.chromium.base.test.util.CommandLineFlags;
+import org.chromium.base.test.util.DoNotBatch;
+import org.chromium.base.test.util.Feature;
+import org.chromium.base.test.util.Features;
+import org.chromium.blink_public.common.BlinkFeatures;
+import org.chromium.content_public.browser.Visibility;
+import org.chromium.content_public.common.ContentSwitches;
+import org.chromium.js_injection.mojom.DocumentInjectionTime;
+import org.chromium.net.test.EmbeddedTestServer.ServerHTTPSSetting;
+import org.chromium.net.test.ServerCertificate;
+
+/** Tests for WebMessageListener interaction with Prerendering. */
+@RunWith(Parameterized.class)
+@UseParametersRunnerFactory(AwJUnit4ClassRunnerWithParameters.Factory.class)
+@DoNotBatch(reason = "Tests that need browser start are incompatible with @Batch")
+public class WebMessageListenerPrerenderTest extends AwParameterizedTest {
+
+    private static final String SERVER_DATA_DIR = "android_webview/test/data";
+    private static final String INDEX_PATH = "/prerender-webmessage-index.html";
+    private static final String PRERENDERED_PATH = "/prerender-webmessage-prerendered.html";
+    private static final String JAVASCRIPT_WORLD_NAME = "app_world";
+
+    @Rule public AwActivityTestRule mActivityTestRule;
+
+    private AwContents mAwContents;
+    private final TestAwContentsClient mContentsClient = new TestAwContentsClient();
+
+    private AwEmbeddedTestServer mTestServer;
+
+    public WebMessageListenerPrerenderTest(AwSettingsMutation param) {
+        mActivityTestRule = new AwActivityTestRule(param.getMutation());
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        mAwContents =
+                mActivityTestRule
+                        .createAwTestContainerViewOnMainSync(mContentsClient)
+                        .getAwContents();
+        AwActivityTestRule.enableJavaScriptOnUiThread(mAwContents);
+        mTestServer = new AwEmbeddedTestServer();
+        // Prerendering requires pages to be loaded over HTTPS.
+        mTestServer.initializeNative(
+                InstrumentationRegistry.getInstrumentation().getContext(),
+                ServerHTTPSSetting.USE_HTTPS);
+        mTestServer.addDefaultHandlers(SERVER_DATA_DIR);
+        mTestServer.setSSLConfig(ServerCertificate.CERT_OK);
+        mTestServer.start();
+    }
+
+    @After
+    public void tearDown() {
+        if (mTestServer != null) {
+            mTestServer.stopAndDestroyServer();
+        }
+    }
+
+    @SuppressLint("VisibleForTests")
+    @Test
+    @LargeTest
+    @Feature({"AndroidWebView", "JsJavaInteraction"})
+    @Features.DisableFeatures({BlinkFeatures.PRERENDER2_MEMORY_CONTROLS})
+    @CommandLineFlags.Add({ContentSwitches.HOST_RESOLVER_RULES + "=MAP * 127.0.0.1"})
+    public void testSharedPrerenderQueueMisroutesAcrossListeners() throws Throwable {
+        final TestWebMessageListener listenerA = new TestWebMessageListener();
+        final TestWebMessageListener listenerB = new TestWebMessageListener();
+        final TestWebMessageListener listenerWorld = new TestWebMessageListener();
+        ThreadUtils.runOnUiThreadBlocking(
+                () -> {
+                    mAwContents
+                            .getSettings()
+                            .setSpeculativeLoadingAllowed(
+                                    SpeculativeLoadingAllowedFlags.PRERENDER_ENABLED);
+                    mAwContents.getWebContents().updateWebContentsVisibility(Visibility.VISIBLE);
+
+                    mAwContents.addWebMessageListener("aListener", new String[] {"*"}, listenerA);
+                    mAwContents.addWebMessageListener("bListener", new String[] {"*"}, listenerB);
+
+                    mAwContents.registerJavaScriptWorld(JAVASCRIPT_WORLD_NAME);
+                    mAwContents.addWebMessageListener(
+                            "worldListener",
+                            new String[] {"*"},
+                            listenerWorld,
+                            JAVASCRIPT_WORLD_NAME);
+
+                    mAwContents.addJavaScriptOnEvent(
+                            """
+                            if (document.prerendering) {
+                              worldListener.postMessage('msg-for-world');
+                            }
+                            """,
+                            DocumentInjectionTime.DOCUMENT_START,
+                            new String[] {"*"},
+                            JAVASCRIPT_WORLD_NAME);
+                });
+
+        // Load a page that has speculation rules to prerender the PRERENDERED_PATH page.
+        mActivityTestRule.loadUrlSync(
+                mAwContents,
+                mContentsClient.getOnPageFinishedHelper(),
+                mTestServer.getURL(INDEX_PATH));
+
+        // Wait for both main and prerendered page to send a console message. This indicates they
+        // are loaded and running JavaScript.
+        mContentsClient.getAddMessageToConsoleHelper().waitForCallback(0, 2);
+
+        // Activate the prerendered page. This should trigger all the messages in the queue to be
+        // sent to the app.
+        mActivityTestRule.loadUrlAsync(mAwContents, mTestServer.getURL(PRERENDERED_PATH));
+
+        // Now check messages.
+        TestWebMessageListener.Data dataA = listenerA.waitForOnPostMessage();
+        Assert.assertEquals("msg-for-a", dataA.getAsString());
+        Assert.assertTrue(listenerA.hasNoMoreOnPostMessage());
+
+        TestWebMessageListener.Data dataB = listenerB.waitForOnPostMessage();
+        Assert.assertEquals("msg-for-b", dataB.getAsString());
+        Assert.assertTrue(listenerB.hasNoMoreOnPostMessage());
+
+        TestWebMessageListener.Data dataWorld = listenerWorld.waitForOnPostMessage();
+        Assert.assertEquals("msg-for-world", dataWorld.getAsString());
+        Assert.assertTrue(listenerWorld.hasNoMoreOnPostMessage());
+    }
+}
diff --git a/android_webview/test/BUILD.gn b/android_webview/test/BUILD.gn
index c9523a33..83215c5 100644
--- a/android_webview/test/BUILD.gn
+++ b/android_webview/test/BUILD.gn
@@ -538,6 +538,7 @@
     "../javatests/src/org/chromium/android_webview/test/VisualStateTest.java",
     "../javatests/src/org/chromium/android_webview/test/WebExposedTest.java",
     "../javatests/src/org/chromium/android_webview/test/WebKitHitTestTest.java",
+    "../javatests/src/org/chromium/android_webview/test/WebMessageListenerPrerenderTest.java",
     "../javatests/src/org/chromium/android_webview/test/WebViewAsynchronousFindApisTest.java",
     "../javatests/src/org/chromium/android_webview/test/WebViewFindApisTestRule.java",
     "../javatests/src/org/chromium/android_webview/test/WebViewModalDialogOverrideTest.java",
diff --git a/android_webview/test/data/prerender-webmessage-index.html b/android_webview/test/data/prerender-webmessage-index.html
new file mode 100644
index 0000000..dca43944
--- /dev/null
+++ b/android_webview/test/data/prerender-webmessage-index.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <script type="speculationrules">
+      {"prerender": [
+        {"source": "list",
+         "urls": ["/prerender-webmessage-prerendered.html"]
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/WebMessageListenerPrerenderTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/WebMessageListenerPrerenderTest.java
new file mode 100644
index 0000000..38ccdca
--- /dev/null
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/WebMessageListenerPrerenderTest.java
@@ -0,0 +1,147 @@
+// 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.android_webview.test;
+
+import android.annotation.SuppressLint;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.LargeTest;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.UseParametersRunnerFactory;
+
+import org.chromium.android_webview.AwContents;
+import org.chromium.android_webview.settings.SpeculativeLoadingAllowedFlags;
+import org.chromium.base.ThreadUtils;
+import org.chromium.base.test.util.CommandLineFlags;
+import org.chromium.base.test.util.DoNotBatch;
+import org.chromium.base.test.util.Feature;
+import org.chromium.base.test.util.Features;
+import org.chromium.blink_public.common.BlinkFeatures;
+import org.chromium.content_public.browser.Visibility;
+import org.chromium.content_public.common.ContentSwitches;
+import org.chromium.js_injection.mojom.DocumentInjectionTime;
+import org.chromium.net.test.EmbeddedTestServer.ServerHTTPSSetting;
+import org.chromium.net.test.ServerCertificate;
+
+/** Tests for WebMessageListener interaction with Prerendering. */
+@RunWith(Parameterized.class)
+@UseParametersRunnerFactory(AwJUnit4ClassRunnerWithParameters.Factory.class)
+@DoNotBatch(reason = "Tests that need browser start are incompatible with @Batch")
+public class WebMessageListenerPrerenderTest extends AwParameterizedTest {
+
+    private static final String SERVER_DATA_DIR = "android_webview/test/data";
+    private static final String INDEX_PATH = "/prerender-webmessage-index.html";
+    private static final String PRERENDERED_PATH = "/prerender-webmessage-prerendered.html";
+    private static final String JAVASCRIPT_WORLD_NAME = "app_world";
+
+    @Rule public AwActivityTestRule mActivityTestRule;
+
+    private AwContents mAwContents;
+    private final TestAwContentsClient mContentsClient = new TestAwContentsClient();
+
+    private AwEmbeddedTestServer mTestServer;
+
+    public WebMessageListenerPrerenderTest(AwSettingsMutation param) {
+        mActivityTestRule = new AwActivityTestRule(param.getMutation());
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        mAwContents =
+                mActivityTestRule
+                        .createAwTestContainerViewOnMainSync(mContentsClient)
+                        .getAwContents();
+        AwActivityTestRule.enableJavaScriptOnUiThread(mAwContents);
+        mTestServer = new AwEmbeddedTestServer();
+        // Prerendering requires pages to be loaded over HTTPS.
+        mTestServer.initializeNative(
+                InstrumentationRegistry.getInstrumentation().getContext(),
+                ServerHTTPSSetting.USE_HTTPS);
+        mTestServer.addDefaultHandlers(SERVER_DATA_DIR);
+        mTestServer.setSSLConfig(ServerCertificate.CERT_OK);
+        mTestServer.start();
+    }
+
+    @After
+    public void tearDown() {
+        if (mTestServer != null) {
+            mTestServer.stopAndDestroyServer();
+        }
+    }
+
+    @SuppressLint("VisibleForTests")
+    @Test
+    @LargeTest
+    @Feature({"AndroidWebView", "JsJavaInteraction"})
+    @Features.DisableFeatures({BlinkFeatures.PRERENDER2_MEMORY_CONTROLS})
+    @CommandLineFlags.Add({ContentSwitches.HOST_RESOLVER_RULES + "=MAP * 127.0.0.1"})
+    public void testSharedPrerenderQueueMisroutesAcrossListeners() throws Throwable {
+        final TestWebMessageListener listenerA = new TestWebMessageListener();
+        final TestWebMessageListener listenerB = new TestWebMessageListener();
+        final TestWebMessageListener listenerWorld = new TestWebMessageListener();
+        ThreadUtils.runOnUiThreadBlocking(
+                () -> {
+                    mAwContents
+                            .getSettings()
+                            .setSpeculativeLoadingAllowed(
+                                    SpeculativeLoadingAllowedFlags.PRERENDER_ENABLED);
+                    mAwContents.getWebContents().updateWebContentsVisibility(Visibility.VISIBLE);
+
+                    mAwContents.addWebMessageListener("aListener", new String[] {"*"}, listenerA);
+                    mAwContents.addWebMessageListener("bListener", new String[] {"*"}, listenerB);
+
+                    mAwContents.registerJavaScriptWorld(JAVASCRIPT_WORLD_NAME);
+                    mAwContents.addWebMessageListener(
+                            "worldListener",
+                            new String[] {"*"},
+                            listenerWorld,
+                            JAVASCRIPT_WORLD_NAME);
+
+                    mAwContents.addJavaScriptOnEvent(
+                            """
+                            if (document.prerendering) {
+                              worldListener.postMessage('msg-for-world');
+                            }
+                            """,
+                            DocumentInjectionTime.DOCUMENT_START,
+                            new String[] {"*"},
+                            JAVASCRIPT_WORLD_NAME);
+                });
+
+        // Load a page that has speculation rules to prerender the PRERENDERED_PATH page.
+        mActivityTestRule.loadUrlSync(
+                mAwContents,
+                mContentsClient.getOnPageFinishedHelper(),
+                mTestServer.getURL(INDEX_PATH));
+
+        // Wait for both main and prerendered page to send a console message. This indicates they
+        // are loaded and running JavaScript.
+        mContentsClient.getAddMessageToConsoleHelper().waitForCallback(0, 2);
+
+        // Activate the prerendered page. This should trigger all the messages in the queue to be
+        // sent to the app.
+        mActivityTestRule.loadUrlAsync(mAwContents, mTestServer.getURL(PRERENDERED_PATH));
+
+        // Now check messages.
+        TestWebMessageListener.Data dataA = listenerA.waitForOnPostMessage();
+        Assert.assertEquals("msg-for-a", dataA.getAsString());
+        Assert.assertTrue(listenerA.hasNoMoreOnPostMessage());
+
+        TestWebMessageListener.Data dataB = listenerB.waitForOnPostMessage();
+        Assert.assertEquals("msg-for-b", dataB.getAsString());
+        Assert.assertTrue(listenerB.hasNoMoreOnPostMessage());
+
+        TestWebMessageListener.Data dataWorld = listenerWorld.waitForOnPostMessage();
+        Assert.assertEquals("msg-for-world", dataWorld.getAsString());
+        Assert.assertTrue(listenerWorld.hasNoMoreOnPostMessage());
+    }
+}
diff --git a/android_webview/test/BUILD.gn b/android_webview/test/BUILD.gn
index c9523a33..83215c5 100644
--- a/android_webview/test/BUILD.gn
+++ b/android_webview/test/BUILD.gn
@@ -538,6 +538,7 @@
     "../javatests/src/org/chromium/android_webview/test/VisualStateTest.java",
     "../javatests/src/org/chromium/android_webview/test/WebExposedTest.java",
     "../javatests/src/org/chromium/android_webview/test/WebKitHitTestTest.java",
+    "../javatests/src/org/chromium/android_webview/test/WebMessageListenerPrerenderTest.java",
     "../javatests/src/org/chromium/android_webview/test/WebViewAsynchronousFindApisTest.java",
     "../javatests/src/org/chromium/android_webview/test/WebViewFindApisTestRule.java",
     "../javatests/src/org/chromium/android_webview/test/WebViewModalDialogOverrideTest.java",
diff --git a/android_webview/test/data/prerender-webmessage-index.html b/android_webview/test/data/prerender-webmessage-index.html
new file mode 100644
index 0000000..dca43944
--- /dev/null
+++ b/android_webview/test/data/prerender-webmessage-index.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <script type="speculationrules">
+      {"prerender": [
+        {"source": "list",
+         "urls": ["/prerender-webmessage-prerendered.html"]
+         }
+        ]
+     }
+    </script>
+  </head>
+  <script>
+    console.log("running main page");
+  </script>
+</html>
diff --git a/android_webview/test/data/prerender-webmessage-prerendered.html b/android_webview/test/data/prerender-webmessage-prerendered.html
new file mode 100644
index 0000000..6578d95
--- /dev/null
+++ b/android_webview/test/data/prerender-webmessage-prerendered.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+  <body>
+    <script>
+      console.log("running prerenderered page");
+      aListener.postMessage("msg-for-a");
+      bListener.postMessage("msg-for-b");
+    </script>
+  </body>
+</html>
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential cross-listener message spoofing in WebView via shared prerender queue

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 without the Chrome Security team.

Overview: When an Android WebView app uses multiple WebMessageListeners and enables prerendering, messages queued during the prerendering phase are stored in a single shared per-document queue. Upon page activation, the first listener processed drains the entire queue, leading to misrouted messages. This could allow an untrusted main-world script to spoof messages to a privileged isolated-world listener.

Affected files:

  • components/js_injection/browser/js_to_browser_messaging.cc
  • components/js_injection/browser/js_communication_host.cc
  • components/js_injection/browser/js_to_browser_messaging.h
  • components/js_injection/browser/js_communication_host.h

Estimated timestamp from git blame: 2024-05-14

Note: These are suggested/potential steps to trigger the issue, as our tooling agent doesn’t yet have the ability to run code to verify an end-to-end exploit.

Technical Details

Android WebView allows apps to inject JavaScript objects into pages using addWebMessageListener. When a page is being prerendered, JsToBrowserMessaging::PostMessage defers message delivery until the page activates. It does this by storing messages in JsToBrowserMessagingDocumentUserData:

// components/js_injection/browser/js_to_browser_messaging.cc:253
if (render_frame_host_->IsInLifecycleState(
        content::RenderFrameHost::LifecycleState::kPrerendering)) {
  JsToBrowserMessagingDocumentUserData* data =
      JsToBrowserMessagingDocumentUserData::GetOrCreateForCurrentDocument(
          render_frame_host_);
  data->queued_messages().push_back(std::move(web_message));
  return;
}

Because JsToBrowserMessagingDocumentUserData inherits from content::DocumentUserData, it is a singleton per RenderFrameHost document. As a result, all JsToBrowserMessaging instances (representing different listeners/worlds) for a given document share the exact same message queue.

Upon activation, JsCommunicationHost::RenderFrameHostStateChanged iterates through all registered listeners. The iteration order is determined by a std::map keyed by std::pair<std::u16string, int32_t> (the listener name and world ID). The first listener in this sorted map that has an initialized host_ will call OnRenderFrameHostActivated(), iterate over the shared queue, dispatch every message to its own handler, and then clear the queue.

Potential Attack Scenario

  1. Prerequisites: A target WebView app explicitly enables speculative loading (SpeculativeLoadingAllowedFlags::PRERENDER_ENABLED). It registers two listeners: a privileged one in an isolated world (e.g., named "appBridge") and a standard one in the main world (e.g., named "pageBridge").
  2. Trigger Prerender: An attacker-controlled main-world page triggers a prerender of another attacker-controlled URL (e.g., via <script type="speculationrules">).
  3. Queue Initialization: During the prerender, the app’s trusted isolated-world script executes and posts an initial benign message to "appBridge". This initializes the host_ for the "appBridge" listener and adds the message to the shared per-document queue.
  4. Payload Injection: The attacker’s main-world script in the prerendered page posts a malicious payload to its own listener: pageBridge.postMessage("exploit_payload"). This payload is appended to the same shared queue.
  5. Activation & Spoofing: The prerendered page is activated. The browser iterates over the listeners. Because "appBridge" sorts alphabetically before "pageBridge", its OnRenderFrameHostActivated() method is called first. It drains the shared queue, successfully receiving the attacker’s "exploit_payload" intended for the less privileged listener.

Suggested Fix

Avoid using a document-level singleton queue to store messages from different listeners.

The queued_messages_ queue should be moved directly into the JsToBrowserMessaging class itself as a private member variable. Because each JsToBrowserMessaging instance corresponds to a specific listener and world, this ensures that messages queued during prerendering are kept strictly separated by destination.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results 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