CVE-2026-95375
Overview
Background
- `<webview>` tag
- An extensions/guest-view element that embeds third-party web content in a separate process, where the embedder page is assumed to be more privileged than the embedded guest.
- `chrome-untrusted://`
- A WebUI scheme (identified by
content::kChromeUIUntrustedScheme) for browser-shipped pages that handle untrustworthy content, such aschrome-untrusted://lens, and which therefore run with reduced privilege. - `webViewInternal` API
- The internal extension function backend behind
<webview>methods likeexecuteScript,insertCSS,addContentScripts,captureVisibleRegion, andloadDataWithBaseUrl. - `EXTENSION_FUNCTION_VALIDATE`
- A macro that aborts an extension function call when its condition is false, used here to reject invocations whose
source_url()is on the untrusted scheme.
Root Cause Analysis
The webViewInternal functions backing powerful <webview> APIs (WebViewInternalExecuteScriptFunction, WebViewInternalInsertCSSFunction, WebViewInternalAddContentScriptsFunction, WebViewInternalCaptureVisibleRegionFunction, and WebViewInternalLoadDataWithBaseUrlFunction) executed without checking the scheme of the caller, implicitly assuming the embedder was strictly higher-privileged than its guest. That assumption is violated when a chrome-untrusted:// page such as chrome-untrusted://lens hosts a <webview>, since the embedder is itself designed to process untrusted content, so granting it script injection, CSS injection, screen capture of the guest, and base-URL-spoofing navigation is an authorization mismatch.
The fix inserts EXTENSION_FUNCTION_VALIDATE(!source_url().SchemeIs(content::kChromeUIUntrustedScheme)) at the start of each affected Run() (adding Run() overrides for the execute-code-derived executeScript/insertCSS functions), so any invocation originating from the untrusted scheme is rejected in the browser process. Complementary renderer-side guards in web_view.js throw ERROR_MSG_UNSUPPORTED_API_IN_CHROME_UNTRUSTED before the IPC is even sent, and these APIs are not needed by the untrusted page, so removing access closes the unnecessary privilege without loss of function.
<webview> embedder as more privileged than its guest, when a chrome-untrusted:// embedder is not; the fix re-establishes the invariant by explicitly banning these powerful APIs whenever the calling source_url() uses the untrusted scheme.Attack Path
- Reach an untrusted embedder
An attacker gets code running in a
chrome-untrusted://context that hosts a<webview>, such as compromised or attacker-influenced content withinchrome-untrusted://lens. - Invoke a powerful webview API
From that context the attacker calls one of the previously unrestricted methods (
executeScript,insertCSS,addContentScripts,captureVisibleRegion, orloadDataWithBaseUrl) on the<webview>, which the browser process formerly serviced without a scheme check. - Gain undue control over the guest
Script/CSS injection lets the embedder run code in the guest,
captureVisibleRegionreads the guest’s rendered pixels, andloadDataWithBaseUrlnavigates the guest with a spoofed base URL, all exceeding the embedder’s intended authority (inferred). - Leverage the mismatch for further escalation
The improper cross-privilege control could be chained toward broader compromise, but the patch itself demonstrates only the authorization bypass, not a concrete follow-on primitive
(inferred).
Impact Assessment
chrome-untrusted:// page could invoke high-privilege <webview> APIs against embedded guest content, obtaining script and CSS injection, visible-region capture, and base-URL-controlled navigation over the guest despite the untrusted context’s reduced privilege. The precondition is attacker-controlled execution within an untrusted embedder that hosts a <webview> (in practice chrome-untrusted://lens), and the advisory classifies this as incorrect authorization in BrowserTag. Any escalation beyond the authorization bypass shown by the patch, and any exploited-in-the-wild status, is not established by the diff and would be (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
Runextensions/browser/api/guest_view/web_view/web_view_internal_api.cc |
modified | |
WebViewInternalExecuteScriptFunctionextensions/browser/api/guest_view/web_view/web_view_internal_api.cc |
modified | |
WebViewInternalInsertCSSFunctionextensions/browser/api/guest_view/web_view/web_view_internal_api.cc |
modified | |
ifextensions/renderer/resources/guest_view/web_view/web_view.js |
modified | |
WebViewElementextensions/renderer/resources/guest_view/web_view/web_view_element.js |
modified | |
ifextensions/renderer/resources/guest_view/web_view/web_view_element.js |
modified | |
TestWebUIControllerchrome/test/data/webui/webview/webui_webview_browsertest.cc |
modified | |
TestWebUIControllerBasechrome/test/data/webui/webview/webui_webview_browsertest.cc |
modified | |
UntrustedWebUIControllerchrome/test/data/webui/webview/webui_webview_browsertest.cc |
modified | |
UntrustedWebUIConfigchrome/test/data/webui/webview/webui_webview_browsertest.cc |
modified | |
WebUIWebViewBrowserTestchrome/test/data/webui/webview/webui_webview_browsertest.cc |
modified | |
UntrustedWebUIWebViewBrowserTestchrome/test/data/webui/webview/webui_webview_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/test/data/webui/webview/webui_webview_browsertest.cc |
modified | |
suitechrome/test/data/webui/webview/webview_untrusted_test.ts |
modified |
Files Changed
extensions/renderer/resources/guest_view/web_view/web_view_element.jsextensions/browser/api/guest_view/web_view/web_view_internal_api.ccextensions/renderer/resources/guest_view/web_view/web_view_constants.jschrome/test/data/webui/webview/BUILD.gnextensions/renderer/resources/guest_view/web_view/web_view.jschrome/test/data/webui/webview/webview_untrusted_test.tstools/typescript/definitions/webview_tag.d.tschrome/test/data/webui/webview/webui_webview_browsertest.ccextensions/browser/api/guest_view/web_view/web_view_internal_api.h
Audit Directions
- Embedder-privilege assumptionsAudit other guest-view/
<webview>and extension APIs that implicitly assume the embedder outranks the guest, and confirm they checksource_url()when a WebUI page can be the embedder. - `chrome-untrusted://` capability surfaceEnumerate every API reachable from
chrome-untrusted://hosts (Lens and future consumers) and verify each powerful capability is either needed or explicitly banned via a scheme check. - Renderer-vs-browser guard parityLook for APIs guarded only in renderer JS (
web_view.js) without a matching browser-sideEXTENSION_FUNCTION_VALIDATE, since a compromised renderer can bypass the JS check.
Patch
From b7ff62a81e7bb7dbf1610e38c9b0172a66915039 Mon Sep 17 00:00:00 2001 From: Kevin McNee <[email protected]> Date: Wed, 19 Aug 2026 17:52:52 -0700 Subject: [PATCH] Restrict the use of several powerful webview tag APIs in chrome-untrusted Several webview tag APIs rely on the embedder being higher privileged than the embedded content. This is blurred by the use of webview by a chrome-untrusted page (currently, only chrome-untrusted://lens). We ban the following in chrome-untrusted, as they are not used by the untrusted page, and having access to them is an unnecessary risk: addContentScripts executeScript insertCSS captureVisibleRegion loadDataWithBaseUrl Bug: 502179319 Change-Id: Ia794d187d04c3933165bc63d761deb33923d2806 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8266642 Reviewed-by: Rebekah Potter <[email protected]> Commit-Queue: Kevin McNee <[email protected]> Reviewed-by: Alex Moshchuk <[email protected]> Cr-Commit-Position: refs/heads/main@{#1682761} --- 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 14ff5b3..7e9cbc3f 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 @@ -27,6 +27,7 @@ #include "content/public/browser/render_view_host.h" #include "content/public/browser/web_contents.h" #include "content/public/common/stop_find_action.h" +#include "content/public/common/url_constants.h" #include "extensions/browser/extension_api_frame_id_map.h" #include "extensions/browser/extensions_browser_client.h" #include "extensions/browser/guest_view/web_view/controlled_frame_embedder_url_fetcher.h" @@ -336,6 +337,9 @@ WebViewInternalCaptureVisibleRegionFunction::Run() { using api::extension_types::ImageDetails; + EXTENSION_FUNCTION_VALIDATE( + !source_url().SchemeIs(content::kChromeUIUntrustedScheme)); + std::optional<web_view_internal::CaptureVisibleRegion::Params> params = web_view_internal::CaptureVisibleRegion::Params::Create(args()); EXTENSION_FUNCTION_VALIDATE(params); @@ -610,9 +614,21 @@ WebViewInternalExecuteScriptFunction::WebViewInternalExecuteScriptFunction() { } +ExtensionFunction::ResponseAction WebViewInternalExecuteScriptFunction::Run() { + EXTENSION_FUNCTION_VALIDATE( + !source_url().SchemeIs(content::kChromeUIUntrustedScheme)); + return WebViewInternalExecuteCodeFunction::Run(); +} + WebViewInternalInsertCSSFunction::WebViewInternalInsertCSSFunction() { } +ExtensionFunction::ResponseAction WebViewInternalInsertCSSFunction::Run() { + EXTENSION_FUNCTION_VALIDATE( + !source_url().SchemeIs(content::kChromeUIUntrustedScheme)); + return WebViewInternalExecuteCodeFunction::Run(); +} + bool WebViewInternalInsertCSSFunction::ShouldInsertCSS() const { return true; } @@ -627,6 +643,9 @@ ExecuteCodeFunction::ResponseAction WebViewInternalAddContentScriptsFunction::Run() { + EXTENSION_FUNCTION_VALIDATE( + !source_url().SchemeIs(content::kChromeUIUntrustedScheme)); + std::optional<web_view_internal::AddContentScripts::Params> params = web_view_internal::AddContentScripts::Params::Create(args()); EXTENSION_FUNCTION_VALIDATE(params); @@ -914,6 +933,9 @@ ExtensionFunction::ResponseAction WebViewInternalLoadDataWithBaseUrlFunction::Run() { + EXTENSION_FUNCTION_VALIDATE( + !source_url().SchemeIs(content::kChromeUIUntrustedScheme)); + std::optional<web_view_internal::LoadDataWithBaseUrl::Params> params = web_view_internal::LoadDataWithBaseUrl::Params::Create(args()); EXTENSION_FUNCTION_VALIDATE(params); diff --git a/extensions/browser/api/guest_view/web_view/web_view_internal_api.h b/extensions/browser/api/guest_view/web_view/web_view_internal_api.h index 03c01ae0..b9f66b6 100644 --- a/extensions/browser/api/guest_view/web_view/web_view_internal_api.h +++ b/extensions/browser/api/guest_view/web_view/web_view_internal_api.h @@ -169,6 +169,9 @@ protected: ~WebViewInternalExecuteScriptFunction() override {} + // ExtensionFunction: + ResponseAction Run() override; + DECLARE_EXTENSION_FUNCTION("webViewInternal.executeScript", WEBVIEWINTERNAL_EXECUTESCRIPT) }; @@ -186,6 +189,9 @@ protected: ~WebViewInternalInsertCSSFunction() override {} + // ExtensionFunction: + ResponseAction Run() override; + bool ShouldInsertCSS() const override; DECLARE_EXTENSION_FUNCTION("webViewInternal.insertCSS", diff --git a/extensions/renderer/resources/guest_view/web_view/web_view.js b/extensions/renderer/resources/guest_view/web_view/web_view.js index 7a0acee..3d19f039 100644 --- a/extensions/renderer/resources/guest_view/web_view/web_view.js +++ b/extensions/renderer/resources/guest_view/web_view/web_view.js @@ -216,6 +216,18 @@ return true; }; +WebViewImpl.prototype.captureVisibleRegion = function(options, callback) { + if (location.protocol === 'chrome-untrusted:') { + throw new Error( + WebViewConstants.ERROR_MSG_UNSUPPORTED_API_IN_CHROME_UNTRUSTED); + } + if (!this.guest.getId()) { + return false; + } + WebViewInternal.captureVisibleRegion(this.guest.getId(), options, callback); + return true; +}; + WebViewImpl.prototype.setUserAgentOverride = function(userAgentOverride) { this.userAgentOverride = userAgentOverride; if (!this.guest.getId()) { @@ -229,6 +241,10 @@ WebViewImpl.prototype.loadDataWithBaseUrl = function( dataUrl, baseUrl, virtualUrl) { + if (location.protocol === 'chrome-untrusted:') { + throw new Error( + WebViewConstants.ERROR_MSG_UNSUPPORTED_API_IN_CHROME_UNTRUSTED); + } if (!this.guest.getId()) { return; } diff --git a/extensions/renderer/resources/guest_view/web_view/web_view_constants.js b/extensions/renderer/resources/guest_view/web_view/web_view_constants.js index f5a1648e..a1ca01b 100644 --- a/extensions/renderer/resources/guest_view/web_view/web_view_constants.js +++ b/extensions/renderer/resources/guest_view/web_view/web_view_constants.js @@ -30,6 +30,8 @@ 'An action has already been taken for this "newwindow" event.', ERROR_MSG_PERMISSION_ACTION_ALREADY_TAKEN: 'Permission has already been decided for this "permissionrequest" event.', + ERROR_MSG_UNSUPPORTED_API_IN_CHROME_UNTRUSTED: + 'This API is not supported in chrome-untrusted contexts.', WARNING_MSG_DIALOG_REQUEST_BLOCKED: '%1 %2 dialog was blocked.', WARNING_MSG_LOAD_ABORTED: 'The load has aborted with error %1: %2. url: %3', WARNING_MSG_NEWWINDOW_REQUEST_BLOCKED: 'A new window was blocked.', diff --git a/extensions/renderer/resources/guest_view/web_view/web_view_element.js b/extensions/renderer/resources/guest_view/web_view/web_view_element.js index 5284cd8d..15418417 100644 --- a/extensions/renderer/resources/guest_view/web_view/web_view_element.js +++ b/extensions/renderer/resources/guest_view/web_view/web_view_element.js @@ -8,11 +8,16 @@ var GuestViewContainerElement = require('guestViewContainerElement').GuestViewContainerElement; +var WebViewConstants = require('webViewConstants').WebViewConstants; var WebViewInternal = getInternalApi('webViewInternal'); class WebViewElement extends GuestViewContainerElement {} WebViewElement.prototype.addContentScripts = function(rules) { + if (location.protocol === 'chrome-untrusted:') { + throw new Error( + WebViewConstants.ERROR_MSG_UNSUPPORTED_API_IN_CHROME_UNTRUSTED); + } var internal = privates(this).internal; return WebViewInternal.addContentScripts( internal.viewInstanceId, rules, () => {}); @@ -25,12 +30,20 @@ }; WebViewElement.prototype.insertCSS = function(var_args) { + if (location.protocol === 'chrome-untrusted:') { + throw new Error( + WebViewConstants.ERROR_MSG_UNSUPPORTED_API_IN_CHROME_UNTRUSTED); + } var internal = privates(this).internal; return internal.executeCode( WebViewInternal.insertCSS, $Array.slice(arguments)); }; WebViewElement.prototype.executeScript = function(var_args) { + if (location.protocol === 'chrome-untrusted:') { + throw new Error( + WebViewConstants.ERROR_MSG_UNSUPPORTED_API_IN_CHROME_UNTRUSTED); + } var internal = privates(this).internal; return internal.executeCode( WebViewInternal.executeScript, $Array.slice(arguments)); diff --git a/tools/typescript/definitions/webview_tag.d.ts b/tools/typescript/definitions/webview_tag.d.ts index 56658d25..9cfcf06 100644 --- a/tools/typescript/definitions/webview_tag.d.ts +++ b/tools/typescript/definitions/webview_tag.d.ts @@ -354,6 +354,11 @@ setUserAgentOverride(userAgent: string): void; getZoom(callback: (zoomFactor: number) => void): void; setZoom(zoomFactor: number, callback?: () => void): void; + loadDataWithBaseUrl( + dataUrl: string, baseUrl: string, virtualUrl?: string): void; + captureVisibleRegion( + options: extensionTypes.ImageDetails|undefined|null, + callback: (dataUrl: string) => void): void; } export function setAudioMuted(mute: boolean): void; diff --git a/chrome/test/data/webui/webview/webui_webview_browsertest.cc b/chrome/test/data/webui/webview/webui_webview_browsertest.cc index fefaa5af3..5f3febcc 100644 --- a/chrome/test/data/webui/webview/webui_webview_browsertest.cc +++ b/chrome/test/data/webui/webview/webui_webview_browsertest.cc @@ -66,26 +66,27 @@ const char* kTestWebViewHost = chrome::kChromeUIChromeSigninHost; // A simple WebUI controller that serves a blank page with a <webview> tag, and -// supports loading files through the chrome://webui-test/ URL. -// Responds with a content of "%DIR_TEST_DATA%/webui/<filename>" if the request -// path has "/test/<filename>" format. -class TestWebUIController : public content::WebUIController { +// supports loading files through the chrome://webui-test/ URL (or as +// chrome-untrusted). Responds with a content of +// "%DIR_TEST_DATA%/webui/<filename>" if the request path has "/test/<filename>" +// format. +class TestWebUIControllerBase : public content::WebUIController { public: - explicit TestWebUIController(content::WebUI* web_ui) + TestWebUIControllerBase(content::WebUI* web_ui, + const std::string& source_name, + const std::string& script_src_csp) : content::WebUIController(web_ui) { - web_ui->SetBindings( - content::BindingsPolicySet({content::BindingsPolicyValue::kWebUi})); content::WebContents* web_contents = web_ui->GetWebContents(); // Necessary for web view to be allowed. extensions::TabHelper::CreateForWebContents(web_contents); content::WebUIDataSource* source = content::WebUIDataSource::CreateAndAdd( - web_contents->GetBrowserContext(), kTestWebViewHost); + web_contents->GetBrowserContext(), source_name); source->SetRequestFilter( - base::BindRepeating(&TestWebUIController::ShouldHandleRequestCallback), - base::BindRepeating(&TestWebUIController::HandleRequestCallback)); + base::BindRepeating( + &TestWebUIControllerBase::ShouldHandleRequestCallback), + base::BindRepeating(&TestWebUIControllerBase::HandleRequestCallback)); source->OverrideContentSecurityPolicy( - network::mojom::CSPDirectiveName::ScriptSrc, - "script-src chrome://webui-test;"); + network::mojom::CSPDirectiveName::ScriptSrc, script_src_csp); } private: @@ -144,6 +145,17 @@ std::move(callback).Run( base::MakeRefCounted<base::RefCountedString>(std::move(contents))); } +}; + +class TestWebUIController : public TestWebUIControllerBase { + public: + explicit TestWebUIController(content::WebUI* web_ui) + : TestWebUIControllerBase(web_ui, + kTestWebViewHost, + "script-src chrome://webui-test;") { + web_ui->SetBindings( + content::BindingsPolicySet({content::BindingsPolicyValue::kWebUi})); + } WEB_UI_CONTROLLER_TYPE_DECL(); }; @@ -157,6 +169,30 @@ : DefaultWebUIConfig(content::kChromeUIScheme, kTestWebViewHost) {} }; +class UntrustedWebUIController : public TestWebUIControllerBase { + public: + explicit UntrustedWebUIController(content::WebUI* web_ui) + : TestWebUIControllerBase(web_ui, + chrome::kChromeUILensUntrustedSidePanelURL, + "script-src chrome-untrusted://webui-test;") { + web_ui->SetBindings(content::BindingsPolicySet({})); + } + + WEB_UI_CONTROLLER_TYPE_DECL(); +}; + +WEB_UI_CONTROLLER_TYPE_IMPL(UntrustedWebUIController) + +class UntrustedWebUIConfig + : public content::DefaultWebUIConfig<UntrustedWebUIController> { + public: + UntrustedWebUIConfig() + : DefaultWebUIConfig(content::kChromeUIUntrustedScheme, + // Lens is currently the only chrome-untrusted:// + // page with webview permission. + chrome::kChromeUILensSidePanelHost) {} +}; + } // namespace class WebUIWebViewBrowserTest : public WebUIMochaBrowserTest { @@ -494,4 +530,41 @@ EXPECT_FALSE(menu.IsItemPresent(IDC_CONTENT_CONTEXT_INSPECTELEMENT)); } +class UntrustedWebUIWebViewBrowserTest : public WebUIMochaBrowserTest { + public: + UntrustedWebUIWebViewBrowserTest() = default; + + void SetUpOnMainThread() override { + base::FilePath test_data_dir; + base::PathService::Get(chrome::DIR_TEST_DATA, &test_data_dir); + embedded_test_server()->ServeFilesFromDirectory(test_data_dir); + ASSERT_TRUE(embedded_test_server()->Start()); + + web_ui_config_registration_ = + std::make_unique<content::ScopedWebUIConfigRegistration>( + std::make_unique<UntrustedWebUIConfig>()); + set_test_loader_scheme(content::kChromeUIUntrustedScheme); + set_test_loader_host(chrome::kChromeUILensSidePanelHost); + ASSERT_TRUE(ui_test_utils::NavigateToURL( + browser(), GURL(chrome::kChromeUILensUntrustedSidePanelURL))); + WebUIMochaBrowserTest::SetUpOnMainThread(); + } + + content::WebContents* GetWebContentsForTesting() { + return browser()->tab_strip_model()->GetActiveWebContents(); + } + + private: + std::unique_ptr<content::ScopedWebUIConfigRegistration> + web_ui_config_registration_; +}; + +IN_PROC_BROWSER_TEST_F(UntrustedWebUIWebViewBrowserTest, BannedApisThrow) { + ASSERT_TRUE(RunTestOnWebContents(GetWebContentsForTesting(), + "webview/webview_untrusted_test.js", + "runMochaTest('WebviewUntrustedBasicTest', " + "'BannedApisThrowInUntrusted')", + true)); +} + #endif // !BUILDFLAG(IS_MAC) diff --git a/chrome/test/data/webui/webview/webview_untrusted_test.ts b/chrome/test/data/webui/webview/webview_untrusted_test.ts new file mode 100644 index 0000000..2c504f66 --- /dev/null +++ b/chrome/test/data/webui/webview/webview_untrusted_test.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {assertThrows} from 'chrome-untrusted://webui-test/chai_assert.js'; + +suite('WebviewUntrustedBasicTest', function() { + function createWebview(): chrome.webviewTag.WebView { + const webview = + document.createElement('webview') as chrome.webviewTag.WebView; + document.body.appendChild(webview); + return webview; + } + + test('BannedApisThrowInUntrusted', async () => { + const webview = createWebview(); + await new Promise(resolve => { + webview.src = 'data:text/html,<html><body>test</body></html>'; + webview.addEventListener('loadstop', resolve); + }); + + assertThrows(() => { + webview.addContentScripts([ + {name: 'rule', matches: ['<all_urls>'], js: {code: 'true;'}}, + ]); + }); + assertThrows(() => { + webview.captureVisibleRegion(null, () => {}); + }); + assertThrows(() => { + webview.executeScript({code: 'true;'}); + }); + assertThrows(() => { + webview.insertCSS({code: 'body { background-color: red; }'}); + }); + assertThrows(() => { + webview.loadDataWithBaseUrl('data:text/html,test', 'https://example.com'); + }); + }); +}); diff --git a/chrome/test/data/webui/webview/BUILD.gn b/chrome/test/data/webui/webview/BUILD.gn ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/test/data/webui/webview/BUILD.gn b/chrome/test/data/webui/webview/BUILD.gn
index afbab66..7f422d6 100644
--- a/chrome/test/data/webui/webview/BUILD.gn
+++ b/chrome/test/data/webui/webview/BUILD.gn
@@ -16,8 +16,13 @@
"//tools/typescript/definitions/webview_tag.d.ts",
]
+ ts_path_mappings = [ "chrome-untrusted://webui-test/*|" +
+ rebase_path("$root_gen_dir/chrome/test/data/webui/tsc/*",
+ target_gen_dir) ]
+
files = [
"webview_basic_test.ts",
"webview_content_script_test.ts",
+ "webview_untrusted_test.ts",
]
}
diff --git a/chrome/test/data/webui/webview/webui_webview_browsertest.cc b/chrome/test/data/webui/webview/webui_webview_browsertest.cc
index fefaa5af3..5f3febcc 100644
--- a/chrome/test/data/webui/webview/webui_webview_browsertest.cc
+++ b/chrome/test/data/webui/webview/webui_webview_browsertest.cc
@@ -66,26 +66,27 @@
const char* kTestWebViewHost = chrome::kChromeUIChromeSigninHost;
// A simple WebUI controller that serves a blank page with a <webview> tag, and
-// supports loading files through the chrome://webui-test/ URL.
-// Responds with a content of "%DIR_TEST_DATA%/webui/<filename>" if the request
-// path has "/test/<filename>" format.
-class TestWebUIController : public content::WebUIController {
+// supports loading files through the chrome://webui-test/ URL (or as
+// chrome-untrusted). Responds with a content of
+// "%DIR_TEST_DATA%/webui/<filename>" if the request path has "/test/<filename>"
+// format.
+class TestWebUIControllerBase : public content::WebUIController {
public:
- explicit TestWebUIController(content::WebUI* web_ui)
+ TestWebUIControllerBase(content::WebUI* web_ui,
+ const std::string& source_name,
+ const std::string& script_src_csp)
: content::WebUIController(web_ui) {
- web_ui->SetBindings(
- content::BindingsPolicySet({content::BindingsPolicyValue::kWebUi}));
content::WebContents* web_contents = web_ui->GetWebContents();
// Necessary for web view to be allowed.
extensions::TabHelper::CreateForWebContents(web_contents);
content::WebUIDataSource* source = content::WebUIDataSource::CreateAndAdd(
- web_contents->GetBrowserContext(), kTestWebViewHost);
+ web_contents->GetBrowserContext(), source_name);
source->SetRequestFilter(
- base::BindRepeating(&TestWebUIController::ShouldHandleRequestCallback),
- base::BindRepeating(&TestWebUIController::HandleRequestCallback));
+ base::BindRepeating(
+ &TestWebUIControllerBase::ShouldHandleRequestCallback),
+ base::BindRepeating(&TestWebUIControllerBase::HandleRequestCallback));
source->OverrideContentSecurityPolicy(
- network::mojom::CSPDirectiveName::ScriptSrc,
- "script-src chrome://webui-test;");
+ network::mojom::CSPDirectiveName::ScriptSrc, script_src_csp);
}
private:
@@ -144,6 +145,17 @@
std::move(callback).Run(
base::MakeRefCounted<base::RefCountedString>(std::move(contents)));
}
+};
+
+class TestWebUIController : public TestWebUIControllerBase {
+ public:
+ explicit TestWebUIController(content::WebUI* web_ui)
+ : TestWebUIControllerBase(web_ui,
+ kTestWebViewHost,
+ "script-src chrome://webui-test;") {
+ web_ui->SetBindings(
+ content::BindingsPolicySet({content::BindingsPolicyValue::kWebUi}));
+ }
WEB_UI_CONTROLLER_TYPE_DECL();
};
@@ -157,6 +169,30 @@
: DefaultWebUIConfig(content::kChromeUIScheme, kTestWebViewHost) {}
};
+class UntrustedWebUIController : public TestWebUIControllerBase {
+ public:
+ explicit UntrustedWebUIController(content::WebUI* web_ui)
+ : TestWebUIControllerBase(web_ui,
+ chrome::kChromeUILensUntrustedSidePanelURL,
+ "script-src chrome-untrusted://webui-test;") {
+ web_ui->SetBindings(content::BindingsPolicySet({}));
+ }
+
+ WEB_UI_CONTROLLER_TYPE_DECL();
+};
+
+WEB_UI_CONTROLLER_TYPE_IMPL(UntrustedWebUIController)
+
+class UntrustedWebUIConfig
+ : public content::DefaultWebUIConfig<UntrustedWebUIController> {
+ public:
+ UntrustedWebUIConfig()
+ : DefaultWebUIConfig(content::kChromeUIUntrustedScheme,
+ // Lens is currently the only chrome-untrusted://
+ // page with webview permission.
+ chrome::kChromeUILensSidePanelHost) {}
+};
+
} // namespace
class WebUIWebViewBrowserTest : public WebUIMochaBrowserTest {
@@ -494,4 +530,41 @@
EXPECT_FALSE(menu.IsItemPresent(IDC_CONTENT_CONTEXT_INSPECTELEMENT));
}
+class UntrustedWebUIWebViewBrowserTest : public WebUIMochaBrowserTest {
+ public:
+ UntrustedWebUIWebViewBrowserTest() = default;
+
+ void SetUpOnMainThread() override {
+ base::FilePath test_data_dir;
+ base::PathService::Get(chrome::DIR_TEST_DATA, &test_data_dir);
+ embedded_test_server()->ServeFilesFromDirectory(test_data_dir);
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ web_ui_config_registration_ =
+ std::make_unique<content::ScopedWebUIConfigRegistration>(
+ std::make_unique<UntrustedWebUIConfig>());
+ set_test_loader_scheme(content::kChromeUIUntrustedScheme);
+ set_test_loader_host(chrome::kChromeUILensSidePanelHost);
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), GURL(chrome::kChromeUILensUntrustedSidePanelURL)));
+ WebUIMochaBrowserTest::SetUpOnMainThread();
+ }
+
+ content::WebContents* GetWebContentsForTesting() {
+ return browser()->tab_strip_model()->GetActiveWebContents();
+ }
+
+ private:
+ std::unique_ptr<content::ScopedWebUIConfigRegistration>
+ web_ui_config_registration_;
+};
+
+IN_PROC_BROWSER_TEST_F(UntrustedWebUIWebViewBrowserTest, BannedApisThrow) {
+ ASSERT_TRUE(RunTestOnWebContents(GetWebContentsForTesting(),
+ "webview/webview_untrusted_test.js",
+ "runMochaTest('WebviewUntrustedBasicTest', "
+ "'BannedApisThrowInUntrusted')",
+ true));
+}
+
#endif // !BUILDFLAG(IS_MAC)
diff --git a/chrome/test/data/webui/webview/webview_untrusted_test.ts b/chrome/test/data/webui/webview/webview_untrusted_test.ts
new file mode 100644
index 0000000..2c504f66
--- /dev/null
+++ b/chrome/test/data/webui/webview/webview_untrusted_test.ts
@@ -0,0 +1,40 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assertThrows} from 'chrome-untrusted://webui-test/chai_assert.js';
+
+suite('WebviewUntrustedBasicTest', function() {
+ function createWebview(): chrome.webviewTag.WebView {
+ const webview =
+ document.createElement('webview') as chrome.webviewTag.WebView;
+ document.body.appendChild(webview);
+ return webview;
+ }
+
+ test('BannedApisThrowInUntrusted', async () => {
+ const webview = createWebview();
+ await new Promise(resolve => {
+ webview.src = 'data:text/html,<html><body>test</body></html>';
+ webview.addEventListener('loadstop', resolve);
+ });
+
+ assertThrows(() => {
+ webview.addContentScripts([
+ {name: 'rule', matches: ['<all_urls>'], js: {code: 'true;'}},
+ ]);
+ });
+ assertThrows(() => {
+ webview.captureVisibleRegion(null, () => {});
+ });
+ assertThrows(() => {
+ webview.executeScript({code: 'true;'});
+ });
+ assertThrows(() => {
+ webview.insertCSS({code: 'body { background-color: red; }'});
+ });
+ assertThrows(() => {
+ webview.loadDataWithBaseUrl('data:text/html,test', 'https://example.com');
+ });
+ });
+});
Original Bug Report
Origin spoofing and PNA bypass in WebViewGuest via loadDataWithBaseUrl
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.
Overview: A compromised WebUI renderer with access to webViewInternal can use loadDataWithBaseUrl to spoof a WebViewGuest’s origin to a local network address. This allows the attacker to inject scripts that bypass Private Network Access (PNA) and CORS protections to exfiltrate data from unauthenticated local endpoints.
Affected files:
extensions/browser/api/guest_view/web_view/web_view_internal_api.ccextensions/browser/extensions_browser_client.ccextensions/browser/guest_view/web_view/web_view_guest.cc
Estimated timestamp from git blame: 2025-08-29
Description
A potential vulnerability in the webViewInternal extension APIs allows a compromised renderer (such as chrome-untrusted://lens) to perform origin spoofing and script injection within a WebViewGuest. This spoofing can be leveraged to bypass Private Network Access (PNA) protections and access sensitive local network resources.
Origin Spoofing via loadDataWithBaseUrl
In extensions/browser/api/guest_view/web_view/web_view_internal_api.cc, WebViewInternalLoadDataWithBaseUrlFunction::Run fails to properly restrict the base_url parameter. The browser process allows any valid URL with an http or https scheme to be used as a base URL without verifying it against the embedder’s origin:
if (!base_url.is_valid() ||
(!base_url.SchemeIsHTTPOrHTTPS() && !base_in_owner_origin)) {
return RespondNow(ExtensionFunction::Error(
"Invalid base URL \"*\".", base_url.possibly_invalid_spec()));
}
Because base_url.SchemeIsHTTPOrHTTPS() evaluates to true for any http/https origin, the second half of the condition is bypassed. A compromised renderer can force the WebViewGuest to commit a data: URL document, but the resulting document will be assigned the spoofed base_url as its security origin.
Script Injection and PNA Bypass
The attacker can then inject arbitrary JavaScript into this spoofed origin using webViewInternal.executeScript. The script is evaluated in an isolated world within the guest renderer, but it inherits the spoofed main-world origin.
If the attacker spoofs a potentially trustworthy local network address (e.g., http://127.0.0.1:8080/), the injected script can issue fetch() requests to that exact address.
Because the request is same-origin with the spoofed document:
- CORS is bypassed:
network::cors::ShouldCheckCorsreturnsfalsefor same-origin requests, so no preflight is issued. - PNA is bypassed:
LocalNetworkAccessChecker::CheckAddressSpaceexplicitly allows the request because it hits thekAllowedPotentiallyTrustworthySameOriginexception, ignoring the fact that the document’s IP address space iskUnknown(derived from thedata:URL).
While WebViewGuest instances use an isolated storage partition, this does not mitigate the attack against local network endpoints that rely solely on the network boundary (PNA) rather than cookie-based authentication.
Potential Reproduction Steps
Note: Our tooling agent does not have the ability to run code. These are suggested steps to verify the vulnerability.
- From a compromised WebUI renderer that has access to the API (e.g.,
chrome-untrusted://lens), create aWebViewGuestusingguestViewInternal.createGuest. - Call
webViewInternal.loadDataWithBaseUrl(guest_instance_id, 'data:text/html,<body>', 'http://127.0.0.1:8080/', 'http://127.0.0.1:8080/')to navigate the guest to a spoofed local origin. - Verify the browser allows the navigation and the guest commits with the origin
http://127.0.0.1:8080. - Call
webViewInternal.executeScript(guest_instance_id, 'http://127.0.0.1:8080/', {code: 'fetch("http://127.0.0.1:8080/secret").then(r => r.text()).then(t => parent.postMessage(t, "*"))'}). - Verify that the
fetchbypasses PNA and CORS, successfully reaching the local endpoint, and that the response is sent back to the embedder.
Suggested Fix
In WebViewInternalLoadDataWithBaseUrlFunction::Run, the validation logic should be tightened to restrict base_url. It should either enforce that base_url is same-origin with the embedder (base_in_owner_origin), or enforce strict ChildProcessSecurityPolicy checks to prevent the embedder from spoofing origins it does not possess privileges for.
For example:
if (!base_url.is_valid() || !base_in_owner_origin) {
// ... error handling
}
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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. And please feel free to reach out to me directly if you have concerns or feedback on the project.