Medium CVSS 6.5 chrome Policy Bypass 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in WebView
DescriptionInsufficient policy enforcement in WebView
ComponentWebView
Bug ClassPolicy Bypass
Tracker513735096
Fix commit781fa2325b60 (chromium/src) +141/-15
CWECWE-284
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-16 (per vendor advisory)
Fix landed2026-05-27 (public git)
Disclosed2026-06-30
Patch gap34 days — fix public in source before the advisory

Changed Functions

FunctionChangeNotes
if
android_webview/browser/supervised_user/aw_supervised_user_throttle.cc
modified

Files Changed

  • android_webview/browser/aw_content_browser_client.cc
  • android_webview/browser/supervised_user/aw_supervised_user_throttle.cc
  • android_webview/javatests/src/org/chromium/android_webview/test/AwActivityTestRule.java
  • android_webview/javatests/src/org/chromium/android_webview/test/AwSupervisedUserTest.java
From 781fa2325b6022b8496a1aa04c0124342425f398 Mon Sep 17 00:00:00 2001
From: Nate Fischer <ntfschr@google.com>
Date: Wed, 27 May 2026 10:42:47 -0700
Subject: [PATCH] Block prerendering of disallowed sites in WebView

Prerendering a restricted/disallowed site could bypass supervised user
restrictions. This change blocks speculative/prerender loads in
AwSupervisedUserThrottle if they occur within a prerendered main frame,
preventing disallowed content from being pre-fetched.

Also adds testPrerenderDisallowedSiteIsBlocked in AwSupervisedUserTest
to prevent future regressions, and upgrades the progress listener helper
to properly handle multiple sequential navigations.

Fixed: 513735096
Test: run_webview_instrumentation_test_apk -f AwSupervisedUserTest.*
Test: Revert the fix and verify the test catches the bug
Change-Id: I8c8916fd2464259948c13a7101dd8dcaf395655e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7861926
Reviewed-by: Peter Pakkenberg <pbirk@chromium.org>
Commit-Queue: Nate Fischer <ntfschr@chromium.org>
Auto-Submit: Nate Fischer <ntfschr@chromium.org>
Commit-Queue: Peter Pakkenberg <pbirk@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1637074}
---

diff --git a/android_webview/browser/aw_content_browser_client.cc b/android_webview/browser/aw_content_browser_client.cc
index 4494c03..4c0014d 100644
--- a/android_webview/browser/aw_content_browser_client.cc
+++ b/android_webview/browser/aw_content_browser_client.cc
@@ -727,7 +727,9 @@
 
   if ((navigation_handle.GetNavigatingFrameType() ==
            FrameType::kPrimaryMainFrame ||
-       navigation_handle.GetNavigatingFrameType() == FrameType::kSubframe) &&
+       navigation_handle.GetNavigatingFrameType() == FrameType::kSubframe ||
+       navigation_handle.GetNavigatingFrameType() ==
+           FrameType::kPrerenderMainFrame) &&
       registry.GetNavigationHandle().GetURL().SchemeIsHTTPOrHTTPS()) {
     AwSupervisedUserUrlClassifier* urlClassifier =
         AwSupervisedUserUrlClassifier::GetInstance();
diff --git a/android_webview/browser/supervised_user/aw_supervised_user_throttle.cc b/android_webview/browser/supervised_user/aw_supervised_user_throttle.cc
index bce359f..ecc044e 100644
--- a/android_webview/browser/supervised_user/aw_supervised_user_throttle.cc
+++ b/android_webview/browser/supervised_user/aw_supervised_user_throttle.cc
@@ -99,6 +99,16 @@
     blocked_ = true;
     pending_checks_ = 0;
 
+    // Cancel non-visible requests (such as prerendered content) with a network
+    // error (but without a blocking page).
+    if (navigation_handle()->IsInPrerenderedMainFrame()) {
+      CancelDeferredNavigation(content::NavigationThrottle::ThrottleCheckResult(
+          NavigationThrottle::CANCEL, net::ERR_BLOCKED_BY_CLIENT));
+      return;
+    }
+
+    // Cancel visible requests (such as HTML content) with both a network error
+    // and with a blocking page.
     std::unique_ptr<security_interstitials::SecurityInterstitialPage>
         blocking_page = AwSupervisedUserBlockingPage::CreateBlockingPage(
             navigation_handle()->GetWebContents(),
@@ -108,7 +118,8 @@
     security_interstitials::SecurityInterstitialTabHelper::
         AssociateBlockingPage(navigation_handle(), std::move(blocking_page));
     CancelDeferredNavigation(content::NavigationThrottle::ThrottleCheckResult(
-        CANCEL, net::ERR_BLOCKED_BY_CLIENT, error_page_content));
+        NavigationThrottle::CANCEL, net::ERR_BLOCKED_BY_CLIENT,
+        error_page_content));
 
   } else {
     if (pending_checks_ == 0 && deferred_) {
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/AwActivityTestRule.java b/android_webview/javatests/src/org/chromium/android_webview/test/AwActivityTestRule.java
index e85a6f7..57c8bb2 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/AwActivityTestRule.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/AwActivityTestRule.java
@@ -607,7 +607,7 @@
         ThreadUtils.runOnUiThreadBlocking(() -> awContents.destroy());
     }
 
-    public String getTitleOnUiThread(final AwContents awContents) throws Exception {
+    public String getTitleOnUiThread(final AwContents awContents) {
         return ThreadUtils.runOnUiThreadBlocking(() -> awContents.getTitle());
     }
 
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/AwSupervisedUserTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/AwSupervisedUserTest.java
index 3ee63db..e6b5ee3 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/AwSupervisedUserTest.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/AwSupervisedUserTest.java
@@ -42,6 +42,7 @@
 import org.chromium.android_webview.common.AwSupervisedUserUrlClassifierDelegate;
 import org.chromium.android_webview.common.BackgroundThreadExecutor;
 import org.chromium.android_webview.common.PlatformServiceBridge;
+import org.chromium.android_webview.settings.SpeculativeLoadingAllowedFlags;
 import org.chromium.android_webview.supervised_user.AwSupervisedUserSafeModeAction;
 import org.chromium.android_webview.supervised_user.AwSupervisedUserUrlClassifier;
 import org.chromium.base.Callback;
@@ -113,7 +114,7 @@
             new TestAwSupervisedUserUrlClassifierDelegate();
     private AwContents mAwContents;
     private TestWebServer mWebServer;
-    private final IframeLoadedListener mIframeLoadedListener = new IframeLoadedListener();
+    private final TestWebMessageListener mIframeLoadedListener = new TestWebMessageListener();
 
     public AwSupervisedUserTest(AwSettingsMutation param) {
         this.mActivityTestRule = new AwActivityTestRule(param.getMutation());
@@ -227,13 +228,9 @@
         // is the best option.
         CriteriaHelper.pollInstrumentationThread(
                 () -> {
-                    try {
-                        Criteria.checkThat(
-                                mActivityTestRule.getTitleOnUiThread(mAwContents),
-                                Matchers.is(BLOCKED_SITE_TITLE));
-                    } catch (Exception e) {
-                        throw new RuntimeException(e);
-                    }
+                    Criteria.checkThat(
+                            mActivityTestRule.getTitleOnUiThread(mAwContents),
+                            Matchers.is(BLOCKED_SITE_TITLE));
                 });
     }
 
@@ -280,6 +277,122 @@
         assertIframeTitle(MATURE_SITE_IFRAME_TITLE);
     }
 
+    @Test
+    @SmallTest
+    @Feature({"AndroidWebView"})
+    public void testPrerenderDisallowedSiteIsBlocked() throws Throwable {
+        mActivityTestRule
+                .getAwSettingsOnUiThread(mAwContents)
+                .setSpeculativeLoadingAllowed(SpeculativeLoadingAllowedFlags.PRERENDER_ENABLED);
+
+        String matureUrl = setUpWebPage(MATURE_SITE_PATH, MATURE_SITE_TITLE, null);
+        String safeUrl = setUpWebPage(SAFE_SITE_PATH, SAFE_SITE_TITLE, null);
+
+        loadUrl(safeUrl);
+        assertPageTitle(SAFE_SITE_TITLE);
+
+        injectSpeculationRules(matureUrl);
+        loadUrl(matureUrl);
+        CriteriaHelper.pollInstrumentationThread(
+                () -> {
+                    Criteria.checkThat(
+                            mActivityTestRule.getTitleOnUiThread(mAwContents),
+                            Matchers.is(BLOCKED_SITE_TITLE));
+                });
+
+        // If we get this far, then it means the page was correctly blocked (and prerender didn't
+        // cause the page to slip through navigation).
+
+        Assert.assertEquals(
+                "The unsafe test site should be blocked before the prerender starts",
+                0,
+                mWebServer.getRequestCount(MATURE_SITE_PATH));
+    }
+
+    @Test
+    @SmallTest
+    @Feature({"AndroidWebView"})
+    public void testSafeSitesCanBePrerendered() throws Throwable {
+        final TestWebMessageListener prerenderStatusListener = new TestWebMessageListener();
+        ThreadUtils.runOnUiThreadBlocking(
+                () -> {
+                    mAwContents.addWebMessageListener(
+                            "prerenderStatusListener", new String[] {"*"}, prerenderStatusListener);
+                });
+
+        mActivityTestRule
+                .getAwSettingsOnUiThread(mAwContents)
+                .setSpeculativeLoadingAllowed(SpeculativeLoadingAllowedFlags.PRERENDER_ENABLED);
+
+        String prerenderSafeUrl = setUpPrerenderSafePage();
+        String safeUrl = setUpWebPage(SAFE_SITE_PATH, SAFE_SITE_TITLE, null);
+
+        loadUrl(safeUrl);
+        assertPageTitle(SAFE_SITE_TITLE);
+
+        injectSpeculationRules(prerenderSafeUrl);
+
+        // We poll for the subresource request "/prerendered_ready.png" to ensure the main page has
+        // been fully downloaded, parsed, and has started prerendering. If we don't wait for
+        // prerendering to start, then the prerender and loadUrl() call will race and this may not
+        // count as a prerendered navigation, which defeats the purpose of this test case.
+        CriteriaHelper.pollInstrumentationThread(
+                () -> {
+                    Criteria.checkThat(
+                            mWebServer.getRequestCount("/prerendered_ready.png"),
+                            Matchers.greaterThan(0));
+                });
+
+        // Verify that we can still navigate to the safe page.
+        loadUrl(prerenderSafeUrl);
+        assertPageTitle("Prerender Safe site");
+
+        // And verify that this navigation was for a page that was prerendered.
+        Assert.assertEquals("prerendered_and_activated", prerenderStatusListener.waitForResult());
+    }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/AwActivityTestRule.java b/android_webview/javatests/src/org/chromium/android_webview/test/AwActivityTestRule.java
index e85a6f7..57c8bb2 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/AwActivityTestRule.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/AwActivityTestRule.java
@@ -607,7 +607,7 @@
         ThreadUtils.runOnUiThreadBlocking(() -> awContents.destroy());
     }
 
-    public String getTitleOnUiThread(final AwContents awContents) throws Exception {
+    public String getTitleOnUiThread(final AwContents awContents) {
         return ThreadUtils.runOnUiThreadBlocking(() -> awContents.getTitle());
     }
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/AwSupervisedUserTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/AwSupervisedUserTest.java
index 3ee63db..e6b5ee3 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/AwSupervisedUserTest.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/AwSupervisedUserTest.java
@@ -42,6 +42,7 @@
 import org.chromium.android_webview.common.AwSupervisedUserUrlClassifierDelegate;
 import org.chromium.android_webview.common.BackgroundThreadExecutor;
 import org.chromium.android_webview.common.PlatformServiceBridge;
+import org.chromium.android_webview.settings.SpeculativeLoadingAllowedFlags;
 import org.chromium.android_webview.supervised_user.AwSupervisedUserSafeModeAction;
 import org.chromium.android_webview.supervised_user.AwSupervisedUserUrlClassifier;
 import org.chromium.base.Callback;
@@ -113,7 +114,7 @@
             new TestAwSupervisedUserUrlClassifierDelegate();
     private AwContents mAwContents;
     private TestWebServer mWebServer;
-    private final IframeLoadedListener mIframeLoadedListener = new IframeLoadedListener();
+    private final TestWebMessageListener mIframeLoadedListener = new TestWebMessageListener();
 
     public AwSupervisedUserTest(AwSettingsMutation param) {
         this.mActivityTestRule = new AwActivityTestRule(param.getMutation());
@@ -227,13 +228,9 @@
         // is the best option.
         CriteriaHelper.pollInstrumentationThread(
                 () -> {
-                    try {
-                        Criteria.checkThat(
-                                mActivityTestRule.getTitleOnUiThread(mAwContents),
-                                Matchers.is(BLOCKED_SITE_TITLE));
-                    } catch (Exception e) {
-                        throw new RuntimeException(e);
-                    }
+                    Criteria.checkThat(
+                            mActivityTestRule.getTitleOnUiThread(mAwContents),
+                            Matchers.is(BLOCKED_SITE_TITLE));
                 });
     }
 
@@ -280,6 +277,122 @@
         assertIframeTitle(MATURE_SITE_IFRAME_TITLE);
     }
 
+    @Test
+    @SmallTest
+    @Feature({"AndroidWebView"})
+    public void testPrerenderDisallowedSiteIsBlocked() throws Throwable {
+        mActivityTestRule
+                .getAwSettingsOnUiThread(mAwContents)
+                .setSpeculativeLoadingAllowed(SpeculativeLoadingAllowedFlags.PRERENDER_ENABLED);
+
+        String matureUrl = setUpWebPage(MATURE_SITE_PATH, MATURE_SITE_TITLE, null);
+        String safeUrl = setUpWebPage(SAFE_SITE_PATH, SAFE_SITE_TITLE, null);
+
+        loadUrl(safeUrl);
+        assertPageTitle(SAFE_SITE_TITLE);
+
+        injectSpeculationRules(matureUrl);
+        loadUrl(matureUrl);
+        CriteriaHelper.pollInstrumentationThread(
+                () -> {
+                    Criteria.checkThat(
+                            mActivityTestRule.getTitleOnUiThread(mAwContents),
+                            Matchers.is(BLOCKED_SITE_TITLE));
+                });
+
+        // If we get this far, then it means the page was correctly blocked (and prerender didn't
+        // cause the page to slip through navigation).
+
+        Assert.assertEquals(
+                "The unsafe test site should be blocked before the prerender starts",
+                0,
+                mWebServer.getRequestCount(MATURE_SITE_PATH));
+    }
+
+    @Test
+    @SmallTest
+    @Feature({"AndroidWebView"})
+    public void testSafeSitesCanBePrerendered() throws Throwable {
+        final TestWebMessageListener prerenderStatusListener = new TestWebMessageListener();
+        ThreadUtils.runOnUiThreadBlocking(
+                () -> {
+                    mAwContents.addWebMessageListener(
+                            "prerenderStatusListener", new String[] {"*"}, prerenderStatusListener);
+                });
+
+        mActivityTestRule
+                .getAwSettingsOnUiThread(mAwContents)
+                .setSpeculativeLoadingAllowed(SpeculativeLoadingAllowedFlags.PRERENDER_ENABLED);
+
+        String prerenderSafeUrl = setUpPrerenderSafePage();
+        String safeUrl = setUpWebPage(SAFE_SITE_PATH, SAFE_SITE_TITLE, null);
+
+        loadUrl(safeUrl);
+        assertPageTitle(SAFE_SITE_TITLE);
+
+        injectSpeculationRules(prerenderSafeUrl);
+
+        // We poll for the subresource request "/prerendered_ready.png" to ensure the main page has
+        // been fully downloaded, parsed, and has started prerendering. If we don't wait for
+        // prerendering to start, then the prerender and loadUrl() call will race and this may not
+        // count as a prerendered navigation, which defeats the purpose of this test case.
+        CriteriaHelper.pollInstrumentationThread(
+                () -> {
+                    Criteria.checkThat(
+                            mWebServer.getRequestCount("/prerendered_ready.png"),
+                            Matchers.greaterThan(0));
+                });
+
+        // Verify that we can still navigate to the safe page.
+        loadUrl(prerenderSafeUrl);
+        assertPageTitle("Prerender Safe site");
+
+        // And verify that this navigation was for a page that was prerendered.
+        Assert.assertEquals("prerendered_and_activated", prerenderStatusListener.waitForResult());
+    }
+
+    private String setUpPrerenderSafePage() {
+        mWebServer.setResponseWithNoContentStatus("/prerendered_ready.png");
+        String content =
+                """
+                <html>
+                <head>
+                <title>Prerender Safe site</title>
+                <script>
+                  window.wasPrerendered = document.prerendering;
+                  if (document.prerendering) {
+                    document.addEventListener('prerenderingchange', function() {
+                      prerenderStatusListener.postMessage("prerendered_and_activated");
+                    });
+                  } else {
+                    prerenderStatusListener.postMessage("not_prerendered");
+                  }
+                </script>
+                </head>
+                <body>
+                  <h1>Prerender Safe site</h1>
+                  <img src="/prerendered_ready.png">
+                </body>
+                </html>
+                """;
+        return mWebServer.setResponse("/prerender-safe.html", content, null);
+    }
+
+    private void injectSpeculationRules(String url) throws Exception {
+        final String speculationRulesTemplate =
+                """
+                {
+                  const script = document.createElement('script');
+                  script.type = 'speculationrules';
+                  script.text = '{"prerender": [{"source": "list", "urls": ["%s"]}]}';
+                  document.head.appendChild(script);
+                }
+                """;
+        final String speculationRules = String.format(speculationRulesTemplate, url);
+        mActivityTestRule.executeJavaScriptAndWaitForResult(
+                mAwContents, mContentsClient, speculationRules);
+    }
+
     private String setUpWebPage(String path, String title, @Nullable String iFrameUrl) {
         return mWebServer.setResponse(path, makeTestPage(title, iFrameUrl), null);
     }
@@ -291,7 +404,7 @@
         mContentsClient.waitForFullLoad();
     }
 
-    private void assertPageTitle(String expectedTitle) throws Exception {
+    private void assertPageTitle(String expectedTitle) {
         Assert.assertEquals(expectedTitle, mActivityTestRule.getTitleOnUiThread(mAwContents));
     }
 
@@ -306,17 +419,17 @@
         @Override
         public void onProgressChanged(int progress) {
             super.onProgressChanged(progress);
-            if (progress == 100 && mCallbackHelper.getCallCount() == 0) {
+            if (progress == 100) {
                 mCallbackHelper.notifyCalled();
             }
         }
 
         public void waitForFullLoad() throws TimeoutException {
-            mCallbackHelper.waitForOnly();
+            mCallbackHelper.waitForNext();
         }
     }
 
-    private static class IframeLoadedListener implements WebMessageListener {
+    private static class TestWebMessageListener implements WebMessageListener {
         private final CallbackHelper mCallbackHelper = new CallbackHelper();
         private volatile String mResult;
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.