Overview

Medium
Severity
6.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in BrowserTag
DescriptionIncorrect authorization in BrowserTag
ComponentBrowserTag
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker502179319
Fix commitb7ff62a81e7b (chromium/src) +194/-12
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-04-13 (per vendor advisory)
Fix landed2026-08-20 (public git)
Disclosed2026-09-22
Patch gap33 days — fix public in source before the advisory

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 as chrome-untrusted://lens, and which therefore run with reduced privilege.
`webViewInternal` API
The internal extension function backend behind <webview> methods like executeScript, insertCSS, addContentScripts, captureVisibleRegion, and loadDataWithBaseUrl.
`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.

Key insight
The single mistake was treating every <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

  1. 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 within chrome-untrusted://lens.
  2. Invoke a powerful webview API From that context the attacker calls one of the previously unrestricted methods (executeScript, insertCSS, addContentScripts, captureVisibleRegion, or loadDataWithBaseUrl) on the <webview>, which the browser process formerly serviced without a scheme check.
  3. Gain undue control over the guest Script/CSS injection lets the embedder run code in the guest, captureVisibleRegion reads the guest’s rendered pixels, and loadDataWithBaseUrl navigates the guest with a spoofed base URL, all exceeding the embedder’s intended authority (inferred).
  4. 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

An attacker operating in a 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

FunctionChangeNotes
Run
extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
modified
WebViewInternalExecuteScriptFunction
extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
modified
WebViewInternalInsertCSSFunction
extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
modified
if
extensions/renderer/resources/guest_view/web_view/web_view.js
modified
WebViewElement
extensions/renderer/resources/guest_view/web_view/web_view_element.js
modified
if
extensions/renderer/resources/guest_view/web_view/web_view_element.js
modified
TestWebUIController
chrome/test/data/webui/webview/webui_webview_browsertest.cc
modified
TestWebUIControllerBase
chrome/test/data/webui/webview/webui_webview_browsertest.cc
modified
UntrustedWebUIController
chrome/test/data/webui/webview/webui_webview_browsertest.cc
modified
UntrustedWebUIConfig
chrome/test/data/webui/webview/webui_webview_browsertest.cc
modified
WebUIWebViewBrowserTest
chrome/test/data/webui/webview/webui_webview_browsertest.cc
modified
UntrustedWebUIWebViewBrowserTest
chrome/test/data/webui/webview/webui_webview_browsertest.cc
modified
IN_PROC_BROWSER_TEST_F
chrome/test/data/webui/webview/webui_webview_browsertest.cc
modified
suite
chrome/test/data/webui/webview/webview_untrusted_test.ts
modified

Files Changed

  • extensions/renderer/resources/guest_view/web_view/web_view_element.js
  • extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
  • extensions/renderer/resources/guest_view/web_view/web_view_constants.js
  • chrome/test/data/webui/webview/BUILD.gn
  • extensions/renderer/resources/guest_view/web_view/web_view.js
  • chrome/test/data/webui/webview/webview_untrusted_test.ts
  • tools/typescript/definitions/webview_tag.d.ts
  • chrome/test/data/webui/webview/webui_webview_browsertest.cc
  • extensions/browser/api/guest_view/web_view/web_view_internal_api.h

Audit Directions

  • Embedder-privilege assumptions
    Audit other guest-view/<webview> and extension APIs that implicitly assume the embedder outranks the guest, and confirm they check source_url() when a WebUI page can be the embedder.
  • `chrome-untrusted://` capability surface
    Enumerate 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 parity
    Look for APIs guarded only in renderer JS (web_view.js) without a matching browser-side EXTENSION_FUNCTION_VALIDATE, since a compromised renderer can bypass the JS check.
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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');
+    });
+  });
+});
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • extensions/browser/extensions_browser_client.cc
  • extensions/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:

  1. CORS is bypassed: network::cors::ShouldCheckCors returns false for same-origin requests, so no preflight is issued.
  2. PNA is bypassed: LocalNetworkAccessChecker::CheckAddressSpace explicitly allows the request because it hits the kAllowedPotentiallyTrustworthySameOrigin exception, ignoring the fact that the document’s IP address space is kUnknown (derived from the data: 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.

  1. From a compromised WebUI renderer that has access to the API (e.g., chrome-untrusted://lens), create a WebViewGuest using guestViewInternal.createGuest.
  2. 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.
  3. Verify the browser allows the navigation and the guest commits with the origin http://127.0.0.1:8080.
  4. 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, "*"))'}).
  5. Verify that the fetch bypasses 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.

View on issue tracker