CVE-2026-7946
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Fchrome/test/data/webui/webview/webui_webview_browsertest.cc |
modified | |
suitechrome/test/data/webui/webview/webview_content_script_test.ts |
modified | |
ifextensions/browser/api/guest_view/web_view/web_view_internal_api.cc |
modified | |
forextensions/browser/api/guest_view/web_view/web_view_internal_api.cc |
modified |
Files Changed
chrome/test/data/webui/webview/webui_webview_browsertest.ccchrome/test/data/webui/webview/webview_content_script_test.tsextensions/browser/api/guest_view/web_view/web_view_internal_api.cc
Patch
From 1e338792dc3546aa81ceaa945634bf4f94f781a6 Mon Sep 17 00:00:00 2001
From: Kevin McNee <mcnee@chromium.org>
Date: Tue, 31 Mar 2026 10:24:34 -0700
Subject: [PATCH] Validate script file origin for webview script injection from WebUIs
The webview script injection APIs, executeScript and addContentScripts,
expect relative URLs when specifying a file to inject. If an absolute
URL is incorrectly passed, we ensure that it's still the same origin as
the embedder.
Fixed: 496016840
Change-Id: I2d0a96682565faac3d4b66024f7478abe2da44bd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7714099
Reviewed-by: Charlie Reis <creis@chromium.org>
Commit-Queue: Kevin McNee <mcnee@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1607954}
---
diff --git a/chrome/test/data/webui/webview/webui_webview_browsertest.cc b/chrome/test/data/webui/webview/webui_webview_browsertest.cc
index 2db8e6a..569adf57 100644
--- a/chrome/test/data/webui/webview/webui_webview_browsertest.cc
+++ b/chrome/test/data/webui/webview/webui_webview_browsertest.cc
@@ -16,6 +16,7 @@
#include "build/buildflag.h"
#include "build/config/coverage/buildflags.h"
#include "chrome/app/chrome_command_ids.h"
+#include "chrome/browser/browser_process.h"
#include "chrome/browser/extensions/tab_helper.h"
#include "chrome/browser/glic/test_support/glic_test_environment.h"
#include "chrome/browser/profiles/profile.h"
@@ -30,6 +31,8 @@
#include "chrome/test/base/test_switches.h"
#include "chrome/test/base/ui_test_utils.h"
#include "chrome/test/base/web_ui_mocha_browser_test.h"
+#include "components/prefs/pref_service.h"
+#include "components/webui/chrome_urls/pref_names.h"
#include "content/public/browser/back_forward_cache.h"
#include "content/public/browser/context_menu_params.h"
#include "content/public/browser/render_view_host.h"
@@ -437,6 +440,22 @@
GetTestUrl("empty.html").spec()));
}
+IN_PROC_BROWSER_TEST_F(WebUIWebViewBrowserTest,
+ ExecuteScriptBadUrlFromOtherWebUi) {
+ // Load the victim WebUI first, so that its resources are available to fetch.
+ g_browser_process->local_state()->SetBoolean(
+ chrome_urls::kInternalOnlyUisEnabled, true);
+ content::WebContents* target_webui_window = browser()->OpenURL(
+ content::OpenURLParams(
+ GURL(chrome::kChromeUIWebUIJsErrorURL), content::Referrer(),
+ WindowOpenDisposition::NEW_WINDOW, ui::PAGE_TRANSITION_TYPED, false),
+ /*navigation_handle_callback=*/{});
+ content::WaitForLoadStop(target_webui_window);
+
+ ASSERT_TRUE(RunContentScriptTestCase("ExecuteScriptBadUrlFromOtherWebUi",
+ GetTestUrl("empty.html").spec()));
+}
+
IN_PROC_BROWSER_TEST_F(WebUIWebViewBrowserTest, ContextMenuInspectElement) {
content::ContextMenuParams params;
content::WebContents* web_contents =
diff --git a/chrome/test/data/webui/webview/webview_content_script_test.ts b/chrome/test/data/webui/webview/webview_content_script_test.ts
index 97abb94e..718fcd20 100644
--- a/chrome/test/data/webui/webview/webview_content_script_test.ts
+++ b/chrome/test/data/webui/webview/webview_content_script_test.ts
@@ -2,7 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import {assertEquals, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
+import {assertEquals, assertFalse, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
+import {eventToPromise} from 'chrome://webui-test/test_util.js';
suite('WebviewContentScriptTest', function() {
const REQUEST_TO_COMM_CHANNEL_1 = 'connect';
@@ -29,6 +30,15 @@
});
}
+ function executeScript(
+ webview: chrome.webviewTag.WebView, details: any): Promise<any[]> {
+ return new Promise<any[]>(resolve => {
+ webview.executeScript(details, (results: any[]) => {
+ resolve(results);
+ });
+ });
+ }
+
function getWebviewUrl(): string {
return (window as unknown as Window & {webviewUrl: string}).webviewUrl;
}
@@ -621,4 +631,32 @@
assertEquals(
kExpectedResult, result, `Unexpected test results:\n${result}`);
});
+
+ test('ExecuteScriptBadUrlFromOtherWebUi', async () => {
+ const webview = createWebview();
+ let seenError = false;
+ webview.addEventListener('consolemessage', () => {
+ // If the script runs, it'll log a message that we'll see here.
+ seenError = true;
+ });
+
+ // It does not make sense to pass an absolute URL to these script injection
+ // APIs, especially one that is cross-origin to the embedder. We verify that
+ // this does not execute.
+ const badUrl = 'chrome://webuijserror/webui_js_error.js';
+ webview.addContentScripts([{
+ name: 'evil',
+ matches: ['http://*/*', 'https://*/*'],
+ js: {files: [badUrl]},
+ run_at: 'document_start' as chrome.extensionTypes.RunAt,
+ }]);
+
+ const loadStopPromise = eventToPromise('loadstop', webview);
+ webview.src = getWebviewUrl();
+ await loadStopPromise;
+
+ await executeScript(webview, {file: badUrl});
+
+ assertFalse(seenError, 'Script should not have run');
+ });
});
diff --git a/extensions/browser/api/guest_view/web_view/web_view_internal_api.cc b/extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
index 6375f9d4..fc1edfa 100644
--- a/extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
+++ b/extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
@@ -11,6 +11,7 @@
#include <vector>
#include "base/check_deref.h"
+#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/stringprintf.h"
@@ -40,6 +41,7 @@
#include "extensions/common/user_script.h"
#include "third_party/abseil-cpp/absl/container/flat_hash_set.h"
#include "third_party/skia/include/core/SkBitmap.h"
+#include "url/origin.h"
using content::WebContents;
using extensions::ExtensionResource;
@@ -55,6 +57,10 @@
namespace {
+// Kill switch for the fix for https://crbug.com/496016840
+// TODO(crbug.com/496016840): Remove in M151 or later.
+BASE_FEATURE(kWebviewScriptFileOriginCheck, base::FEATURE_ENABLED_BY_DEFAULT);
+
constexpr std::string_view kCacheKey = "cache";
constexpr std::string_view kCookiesKey = "cookies";
constexpr std::string_view kSessionCookiesKey = "sessionCookies";
@@ -133,6 +139,10 @@
if (items.files) {
for (const std::string& relative : *items.files) {
GURL url = owner_base_url.Resolve(relative);
+ if (!url::IsSameOriginWith(owner_base_url, url) &&
+ base::FeatureList::IsEnabled(kWebviewScriptFileOriginCheck)) {
+ continue;
+ }
if (extension) {
ExtensionResource resource = extension->GetResource(relative);
contents->push_back(UserScript::Content::CreateFile(
@@ -544,6 +554,11 @@
GURL owner_base_url(guest->GetOwnerSiteURL().GetWithEmptyPath());
GURL file_url(owner_base_url.Resolve(file_src));
+ if (!url::IsSameOriginWith(owner_base_url, file_url) &&
+ base::FeatureList::IsEnabled(kWebviewScriptFileOriginCheck)) {
+ return false;
+ }
+
switch (host_id().type) {
case mojom::HostID::HostType::kExtensions:
NOTREACHED();
Regression Test / PoC
diff --git a/chrome/test/data/webui/webview/webui_webview_browsertest.cc b/chrome/test/data/webui/webview/webui_webview_browsertest.cc
index 2db8e6a..569adf57 100644
--- a/chrome/test/data/webui/webview/webui_webview_browsertest.cc
+++ b/chrome/test/data/webui/webview/webui_webview_browsertest.cc
@@ -16,6 +16,7 @@
#include "build/buildflag.h"
#include "build/config/coverage/buildflags.h"
#include "chrome/app/chrome_command_ids.h"
+#include "chrome/browser/browser_process.h"
#include "chrome/browser/extensions/tab_helper.h"
#include "chrome/browser/glic/test_support/glic_test_environment.h"
#include "chrome/browser/profiles/profile.h"
@@ -30,6 +31,8 @@
#include "chrome/test/base/test_switches.h"
#include "chrome/test/base/ui_test_utils.h"
#include "chrome/test/base/web_ui_mocha_browser_test.h"
+#include "components/prefs/pref_service.h"
+#include "components/webui/chrome_urls/pref_names.h"
#include "content/public/browser/back_forward_cache.h"
#include "content/public/browser/context_menu_params.h"
#include "content/public/browser/render_view_host.h"
@@ -437,6 +440,22 @@
GetTestUrl("empty.html").spec()));
}
+IN_PROC_BROWSER_TEST_F(WebUIWebViewBrowserTest,
+ ExecuteScriptBadUrlFromOtherWebUi) {
+ // Load the victim WebUI first, so that its resources are available to fetch.
+ g_browser_process->local_state()->SetBoolean(
+ chrome_urls::kInternalOnlyUisEnabled, true);
+ content::WebContents* target_webui_window = browser()->OpenURL(
+ content::OpenURLParams(
+ GURL(chrome::kChromeUIWebUIJsErrorURL), content::Referrer(),
+ WindowOpenDisposition::NEW_WINDOW, ui::PAGE_TRANSITION_TYPED, false),
+ /*navigation_handle_callback=*/{});
+ content::WaitForLoadStop(target_webui_window);
+
+ ASSERT_TRUE(RunContentScriptTestCase("ExecuteScriptBadUrlFromOtherWebUi",
+ GetTestUrl("empty.html").spec()));
+}
+
IN_PROC_BROWSER_TEST_F(WebUIWebViewBrowserTest, ContextMenuInspectElement) {
content::ContextMenuParams params;
content::WebContents* web_contents =
diff --git a/chrome/test/data/webui/webview/webview_content_script_test.ts b/chrome/test/data/webui/webview/webview_content_script_test.ts
index 97abb94e..718fcd20 100644
--- a/chrome/test/data/webui/webview/webview_content_script_test.ts
+++ b/chrome/test/data/webui/webview/webview_content_script_test.ts
@@ -2,7 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import {assertEquals, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
+import {assertEquals, assertFalse, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
+import {eventToPromise} from 'chrome://webui-test/test_util.js';
suite('WebviewContentScriptTest', function() {
const REQUEST_TO_COMM_CHANNEL_1 = 'connect';
@@ -29,6 +30,15 @@
});
}
+ function executeScript(
+ webview: chrome.webviewTag.WebView, details: any): Promise<any[]> {
+ return new Promise<any[]>(resolve => {
+ webview.executeScript(details, (results: any[]) => {
+ resolve(results);
+ });
+ });
+ }
+
function getWebviewUrl(): string {
return (window as unknown as Window & {webviewUrl: string}).webviewUrl;
}
@@ -621,4 +631,32 @@
assertEquals(
kExpectedResult, result, `Unexpected test results:\n${result}`);
});
+
+ test('ExecuteScriptBadUrlFromOtherWebUi', async () => {
+ const webview = createWebview();
+ let seenError = false;
+ webview.addEventListener('consolemessage', () => {
+ // If the script runs, it'll log a message that we'll see here.
+ seenError = true;
+ });
+
+ // It does not make sense to pass an absolute URL to these script injection
+ // APIs, especially one that is cross-origin to the embedder. We verify that
+ // this does not execute.
+ const badUrl = 'chrome://webuijserror/webui_js_error.js';
+ webview.addContentScripts([{
+ name: 'evil',
+ matches: ['http://*/*', 'https://*/*'],
+ js: {files: [badUrl]},
+ run_at: 'document_start' as chrome.extensionTypes.RunAt,
+ }]);
+
+ const loadStopPromise = eventToPromise('loadstop', webview);
+ webview.src = getWebviewUrl();
+ await loadStopPromise;
+
+ await executeScript(webview, {file: badUrl});
+
+ assertFalse(seenError, 'Script should not have run');
+ });
});
Original Bug Report
Cross-origin resource fetch bypass in WebUI webview script injection
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A compromised WebUI renderer with <webview> permissions can potentially bypass origin isolation to fetch and read arbitrary chrome:// or chrome-untrusted:// resources. This occurs because the webview.executeScript API incorrectly uses the target URL’s scheme and an empty allowed hosts list when creating a WebUIURLLoaderFactory.
Affected files:
extensions/browser/guest_view/web_view/web_ui/web_ui_url_fetcher.ccextensions/browser/api/guest_view/web_view/web_view_internal_api.cccontent/browser/webui/web_ui_url_loader_factory.cc
Estimated timestamp from git blame: 2025-10-01
Summary
A potential vulnerability exists in the WebUI <webview> script and CSS injection APIs (webview.executeScript and webview.insertCSS) that could allow a compromised WebUI renderer to bypass site isolation. If an attacker compromises a WebUI that has <webview> privileges (such as chrome-untrusted://lens/ or chrome://oobe/), they can abuse these APIs to fetch and extract sensitive source code or data from any other chrome:// or chrome-untrusted:// origin.
Note: The following steps are suggested potential steps an attacker might take, as our setup does not have the ability to run code to verify an exploit natively.
Potential Attacker Steps & Technical Details
- Compromise a WebUI: An attacker achieves arbitrary code execution (e.g., via XSS) in a privileged WebUI renderer like
chrome-untrusted://lens/. - Setup Guest Frame: The attacker injects a
<webview>element, navigates it to a controlleddata:URI, and sets up JavaScript traps (e.g.,Proxyobjects on the globalwindow) within the guest frame to capture any subsequently injected scripts. - Invoke Injection API: The attacker calls
document.querySelector('webview').executeScript({file: 'chrome://settings/settings.js'});. - URL Resolution Flaw: In the browser process,
WebViewInternalExecuteCodeFunction::LoadFileForEmbedderresolves thefilestring against the embedder’s origin (extensions/browser/api/guest_view/web_view/web_view_internal_api.cc:545). Because the attacker provided an absolute URL,GURL::Resolvereturnschrome://settings/settings.jsas thefile_url. - Improper Factory Creation:
WebUIURLFetcher::Start()then creates aWebUIURLLoaderFactoryto fetch the file (extensions/browser/guest_view/web_view/web_ui/web_ui_url_fetcher.cc:42). Crucially, it requests the factory using the target URL’s scheme (url_.GetScheme(), which ischrome) instead of the embedder’s scheme (chrome-untrusted). It also passes an empty{}forallowed_hosts. - Security Checks Bypassed: In
WebUIURLLoaderFactory::CreateLoaderAndStart(content/browser/webui/web_ui_url_loader_factory.cc), the request bypasses isolation checks. The scheme check passes because the factory was explicitly created for the target’s scheme. The host check (CHECK(allowed_hosts_.empty() || ...)) trivially passes because the allowed hosts set is empty. - Exfiltration: The cross-origin resource is fetched from
URLDataManagerBackend, bypassing renderer-side CORS, and injected into the attacker-controlled guest frame. The attacker’s JS traps capture the script contents and send them back to the embedder, successfully breaking WebUI isolation boundaries.
Suggested Fix
- Origin Validation: In
WebViewInternalExecuteCodeFunction::LoadFileForEmbedder, explicitly check that the resolvedfile_urlshares the same origin (or at least the same scheme and host) as the embedder’sowner_base_urlbefore allowing the fetch to proceed. - Strict Factory Binding: Ensure
WebUIURLFetcherinstantiates theWebUIURLLoaderFactoryusing the embedder’s scheme and host, rather than deriving it from the potentially attacker-controlled target URL.
Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8
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. Please feel free to reach out to me if you have concerns or feedback.