Medium chrome Logic Error 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in WebView
DescriptionInsufficient policy enforcement in WebView
ComponentWebView
Bug ClassLogic Error
Tracker513735096
Fix commit781fa2325b60 (chromium/src) +141/-15
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

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 <[email protected]>
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 <[email protected]>
Commit-Queue: Nate Fischer <[email protected]>
Auto-Submit: Nate Fischer <[email protected]>
Commit-Queue: Peter Pakkenberg <[email protected]>
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.