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 <[email protected]> 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 <[email protected]> Commit-Queue: Kevin McNee <[email protected]> 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.