Chrome · WebView
CVE-2026-13964
Logic Error in WebView
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifandroid_webview/browser/supervised_user/aw_supervised_user_throttle.cc |
modified |
Files Changed
android_webview/browser/aw_content_browser_client.ccandroid_webview/browser/supervised_user/aw_supervised_user_throttle.ccandroid_webview/javatests/src/org/chromium/android_webview/test/AwActivityTestRule.javaandroid_webview/javatests/src/org/chromium/android_webview/test/AwSupervisedUserTest.java
Patch
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.
References
On This Page