Chrome · WebView
CVE-2026-17915
Logic Error in WebView
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Files Changed
android_webview/javatests/src/org/chromium/android_webview/test/LoadDataWithBaseUrlTest.javacontent/browser/android/web_contents_observer_proxy.cc
Patch
From bc63fb02a5ffe88e8179aaaddc93c2b5e397691b Mon Sep 17 00:00:00 2001
From: Wayne Jackson Jr. <wbjacksonjr@google.com>
Date: Mon, 22 Jun 2026 09:24:07 -0700
Subject: [PATCH] Fix stale base URL cache leading to URL spoofing in Android WebView
In 2016, crbug.com/594001 introduced base_url_of_last_started_data_url_
as a short-term workaround because NavigationController would wipe the
pending entry's base URL if a javascript: URL was executed during a
loadDataWithBaseURL navigation. However, this cache was tied to the
global FrameTree loading state (DidStartLoading / DidStopLoading). If
an overlapping navigation occurred while the original data URL load was
stalled, the cache would not be updated, causing the
WebViewClient.onPageFinished callback to report a spoofed base URL.
Chromium's navigation architecture has since evolved. Specifically, in
June 2018 (crrev.com/c/904988), NavigationControllerImpl was
refactored to route javascript: URLs directly to the renderer without
altering the browser's navigation state. This makes the
base_url_of_last_started_data_url_ cache entirely obsolete.
This CL removes the obsolete workaround, natively fixing the URL
spoofing vulnerability by eliminating the flawed caching mechanism.
Additionally, this CL updates the tests in LoadDataWithBaseUrlTest.
Previously, the tests executed loadDataWithBaseUrlAsync and loadUrlAsync
back-to-back. While logcat sequencing proved the Android UI thread
typically processed these fast enough to hit the pending entry edge case,
it was theoretically non-deterministic. The tests now use runOnMainSync
to batch both calls together in a single UI thread task. Because the
UI thread's message loop is occupied and cannot process the renderer's
DidCommit IPC until the block completes, it is mathematically guaranteed
that the javascript execution hits the NavigationController while the
data URL is strictly in a pending state.
Bug: 506390325
Change-Id: Ieefd101a9f5c369ad6eee105371af1e1a13b65fa
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7950759
Reviewed-by: Jinsuk Kim <jinsukkim@chromium.org>
Reviewed-by: Richard Coles <torne@chromium.org>
Commit-Queue: Wayne Jackson Jr. <wbjacksonjr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1650359}
---
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/LoadDataWithBaseUrlTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/LoadDataWithBaseUrlTest.java
index c1d5f31..f2995cd 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/LoadDataWithBaseUrlTest.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/LoadDataWithBaseUrlTest.java
@@ -4,6 +4,7 @@
package org.chromium.android_webview.test;
+import android.annotation.SuppressLint;
import android.graphics.Bitmap;
import android.util.Pair;
@@ -38,6 +39,7 @@
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.List;
+import java.util.concurrent.CountDownLatch;
/**
* Tests for the {@link android.webkit.WebView#loadDataWithBaseURL(String, String, String, String,
@@ -558,6 +560,60 @@
mAwContents, mContentsClient, "window.gotToEndOfBody"));
}
+ @SuppressLint("SetJavaScriptEnabled")
+ @Test
+ @SmallTest
+ @Feature({"AndroidWebView"})
+ public void testOnPageFinishedBaseUrlSpoof() throws Throwable {
+ CountDownLatch stallLatch = new CountDownLatch(1);
+ try (TestWebServer webServer = TestWebServer.start();
+ TestWebServer stallServer = TestWebServer.startAdditional()) {
+ try {
+ stallServer.setResponseWithRunnableAction(
+ "/stall",
+ "<html><body>stalled</body></html>",
+ java.util.Collections.emptyList(),
+ () -> {
+ try {
+ stallLatch.await();
+ } catch (InterruptedException e) {
+ }
+ });
+ String maliciousUrl =
+ webServer.setResponse(
+ "/evil.html",
+ "<html><body>malicious</body></html>",
+ java.util.Collections.emptyList());
+
+ final String trustedBaseUrl = "https://trusted.example/";
+ final String pageHtml =
+ "<html><body>"
+ + "<img src='"
+ + stallServer.getBaseUrl()
+ + "stall'>"
+ + "<script>setTimeout(() => { location.href = '"
+ + maliciousUrl
+ + "'; }, 100);</script>"
+ + "</body></html>";
+
+ mActivityTestRule.getAwSettingsOnUiThread(mAwContents).setJavaScriptEnabled(true);
+
+ TestCallbackHelperContainer.OnPageFinishedHelper onPageFinishedHelper =
+ mContentsClient.getOnPageFinishedHelper();
+ int callCount = onPageFinishedHelper.getCallCount();
+
+ mActivityTestRule.loadDataWithBaseUrlAsync(
+ mAwContents, pageHtml, "text/html", false, trustedBaseUrl, null);
+
+ onPageFinishedHelper.waitForCallback(callCount, 2);
+ Assert.assertEquals(maliciousUrl, onPageFinishedHelper.getUrl());
+ } finally {
+ stallLatch.countDown();
+ }
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
@Test
@SmallTest
@Feature({"AndroidWebView"})
@@ -567,29 +623,47 @@
// inside NavigationController.
final String pageHtml = "<html><body>Hello, world!</body></html>";
final String baseUrl = "http://example.com/";
+ mActivityTestRule.getAwSettingsOnUiThread(mAwContents).setJavaScriptEnabled(true);
final TestCallbackHelperContainer.OnPageFinishedHelper onPageFinishedHelper =
mContentsClient.getOnPageFinishedHelper();
final int callCount = onPageFinishedHelper.getCallCount();
- mActivityTestRule.loadDataWithBaseUrlAsync(
- mAwContents, pageHtml, "text/html", false, baseUrl, null);
- mActivityTestRule.loadUrlAsync(mAwContents, "javascript:42+42");
+
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ mAwContents.loadDataWithBaseURL(
+ baseUrl, pageHtml, "text/html", "utf-8", null);
+ // The UI thread does not yield, so the commit IPC cannot be processed.
+ // This guarantees the javascript URL hits the pending entry.
+ mAwContents.loadUrl("javascript:42+42");
+ });
+
onPageFinishedHelper.waitForCallback(callCount);
Assert.assertEquals(baseUrl, onPageFinishedHelper.getUrl());
}
+ @SuppressLint("SetJavaScriptEnabled")
@Test
@SmallTest
@Feature({"AndroidWebView"})
public void testOnPageFinishedWithInvalidBaseUrlWhenInterrupted() throws Throwable {
final String pageHtml = CommonResources.ABOUT_HTML;
final String invalidBaseUrl = "http://";
+ mActivityTestRule.getAwSettingsOnUiThread(mAwContents).setJavaScriptEnabled(true);
final TestCallbackHelperContainer.OnPageFinishedHelper onPageFinishedHelper =
mContentsClient.getOnPageFinishedHelper();
final int callCount = onPageFinishedHelper.getCallCount();
- mActivityTestRule.getAwSettingsOnUiThread(mAwContents).setJavaScriptEnabled(true);
- mActivityTestRule.loadDataWithBaseUrlAsync(
- mAwContents, pageHtml, "text/html", false, invalidBaseUrl, null);
- mActivityTestRule.loadUrlAsync(mAwContents, "javascript:42+42");
+
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ mAwContents.loadDataWithBaseURL(
+ invalidBaseUrl, pageHtml, "text/html", "utf-8", null);
+ // The UI thread does not yield, so the commit IPC cannot be processed.
+ // This guarantees the javascript URL hits the pending entry.
+ mAwContents.loadUrl("javascript:42+42");
+ });
+
onPageFinishedHelper.waitForCallback(callCount);
// Verify that the load succeeds. The actual base url is undefined.
Assert.assertEquals(
diff --git a/content/browser/android/web_contents_observer_proxy.cc b/content/browser/android/web_contents_observer_proxy.cc
index bd07fb0..3fa020ad 100644
--- a/content/browser/android/web_contents_observer_proxy.cc
+++ b/content/browser/android/web_contents_observer_proxy.cc
@@ -99,9 +99,6 @@
void WebContentsObserverProxy::DidStartLoading() {
TRACE_EVENT("browser", "WebContentsObserverProxy::DidStartLoading");
JNIEnv* env = AttachCurrentThread();
- if (auto* entry = web_contents()->GetController().GetPendingEntry()) {
- base_url_of_last_started_data_url_ = entry->GetBaseURLForDataURL();
- }
Java_WebContentsObserverProxy_didStartLoading(
env, GetJavaObjectChecked(env),
url::GURLAndroid::FromNativeGURL(env, web_contents()->GetVisibleURL()));
@@ -111,8 +108,6 @@
JNIEnv* env = AttachCurrentThread();
GURL url = web_contents()->GetLastCommittedURL();
bool assume_valid = SetToBaseURLForDataURLIfNeeded(&url);
- // DidStopLoading is the last event we should get.
- base_url_of_last_started_data_url_ = GURL();
Java_WebContentsObserverProxy_didStopLoading(
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/LoadDataWithBaseUrlTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/LoadDataWithBaseUrlTest.java
index c1d5f31..f2995cd 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/LoadDataWithBaseUrlTest.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/LoadDataWithBaseUrlTest.java
@@ -4,6 +4,7 @@
package org.chromium.android_webview.test;
+import android.annotation.SuppressLint;
import android.graphics.Bitmap;
import android.util.Pair;
@@ -38,6 +39,7 @@
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.List;
+import java.util.concurrent.CountDownLatch;
/**
* Tests for the {@link android.webkit.WebView#loadDataWithBaseURL(String, String, String, String,
@@ -558,6 +560,60 @@
mAwContents, mContentsClient, "window.gotToEndOfBody"));
}
+ @SuppressLint("SetJavaScriptEnabled")
+ @Test
+ @SmallTest
+ @Feature({"AndroidWebView"})
+ public void testOnPageFinishedBaseUrlSpoof() throws Throwable {
+ CountDownLatch stallLatch = new CountDownLatch(1);
+ try (TestWebServer webServer = TestWebServer.start();
+ TestWebServer stallServer = TestWebServer.startAdditional()) {
+ try {
+ stallServer.setResponseWithRunnableAction(
+ "/stall",
+ "<html><body>stalled</body></html>",
+ java.util.Collections.emptyList(),
+ () -> {
+ try {
+ stallLatch.await();
+ } catch (InterruptedException e) {
+ }
+ });
+ String maliciousUrl =
+ webServer.setResponse(
+ "/evil.html",
+ "<html><body>malicious</body></html>",
+ java.util.Collections.emptyList());
+
+ final String trustedBaseUrl = "https://trusted.example/";
+ final String pageHtml =
+ "<html><body>"
+ + "<img src='"
+ + stallServer.getBaseUrl()
+ + "stall'>"
+ + "<script>setTimeout(() => { location.href = '"
+ + maliciousUrl
+ + "'; }, 100);</script>"
+ + "</body></html>";
+
+ mActivityTestRule.getAwSettingsOnUiThread(mAwContents).setJavaScriptEnabled(true);
+
+ TestCallbackHelperContainer.OnPageFinishedHelper onPageFinishedHelper =
+ mContentsClient.getOnPageFinishedHelper();
+ int callCount = onPageFinishedHelper.getCallCount();
+
+ mActivityTestRule.loadDataWithBaseUrlAsync(
+ mAwContents, pageHtml, "text/html", false, trustedBaseUrl, null);
+
+ onPageFinishedHelper.waitForCallback(callCount, 2);
+ Assert.assertEquals(maliciousUrl, onPageFinishedHelper.getUrl());
+ } finally {
+ stallLatch.countDown();
+ }
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
@Test
@SmallTest
@Feature({"AndroidWebView"})
@@ -567,29 +623,47 @@
// inside NavigationController.
final String pageHtml = "<html><body>Hello, world!</body></html>";
final String baseUrl = "http://example.com/";
+ mActivityTestRule.getAwSettingsOnUiThread(mAwContents).setJavaScriptEnabled(true);
final TestCallbackHelperContainer.OnPageFinishedHelper onPageFinishedHelper =
mContentsClient.getOnPageFinishedHelper();
final int callCount = onPageFinishedHelper.getCallCount();
- mActivityTestRule.loadDataWithBaseUrlAsync(
- mAwContents, pageHtml, "text/html", false, baseUrl, null);
- mActivityTestRule.loadUrlAsync(mAwContents, "javascript:42+42");
+
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ mAwContents.loadDataWithBaseURL(
+ baseUrl, pageHtml, "text/html", "utf-8", null);
+ // The UI thread does not yield, so the commit IPC cannot be processed.
+ // This guarantees the javascript URL hits the pending entry.
+ mAwContents.loadUrl("javascript:42+42");
+ });
+
onPageFinishedHelper.waitForCallback(callCount);
Assert.assertEquals(baseUrl, onPageFinishedHelper.getUrl());
}
+ @SuppressLint("SetJavaScriptEnabled")
@Test
@SmallTest
@Feature({"AndroidWebView"})
public void testOnPageFinishedWithInvalidBaseUrlWhenInterrupted() throws Throwable {
final String pageHtml = CommonResources.ABOUT_HTML;
final String invalidBaseUrl = "http://";
+ mActivityTestRule.getAwSettingsOnUiThread(mAwContents).setJavaScriptEnabled(true);
final TestCallbackHelperContainer.OnPageFinishedHelper onPageFinishedHelper =
mContentsClient.getOnPageFinishedHelper();
final int callCount = onPageFinishedHelper.getCallCount();
- mActivityTestRule.getAwSettingsOnUiThread(mAwContents).setJavaScriptEnabled(true);
- mActivityTestRule.loadDataWithBaseUrlAsync(
- mAwContents, pageHtml, "text/html", false, invalidBaseUrl, null);
- mActivityTestRule.loadUrlAsync(mAwContents, "javascript:42+42");
+
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ mAwContents.loadDataWithBaseURL(
+ invalidBaseUrl, pageHtml, "text/html", "utf-8", null);
+ // The UI thread does not yield, so the commit IPC cannot be processed.
+ // This guarantees the javascript URL hits the pending entry.
+ mAwContents.loadUrl("javascript:42+42");
+ });
+
onPageFinishedHelper.waitForCallback(callCount);
// Verify that the load succeeds. The actual base url is undefined.
Assert.assertEquals(
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