Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in WebUI
DescriptionInsufficient policy enforcement in WebUI
ComponentWebUI
Bug ClassLogic Error
Tracker496016840
Fix commit1e338792dc35 (chromium/src) +73/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
IN_PROC_BROWSER_TEST_F
chrome/test/data/webui/webview/webui_webview_browsertest.cc
modified
suite
chrome/test/data/webui/webview/webview_content_script_test.ts
modified
if
extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
modified
for
extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
modified

Files Changed

  • chrome/test/data/webui/webview/webui_webview_browsertest.cc
  • chrome/test/data/webui/webview/webview_content_script_test.ts
  • extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
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();
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by vm...@google.com

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.cc
  • extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
  • content/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

  1. Compromise a WebUI: An attacker achieves arbitrary code execution (e.g., via XSS) in a privileged WebUI renderer like chrome-untrusted://lens/.
  2. Setup Guest Frame: The attacker injects a <webview> element, navigates it to a controlled data: URI, and sets up JavaScript traps (e.g., Proxy objects on the global window) within the guest frame to capture any subsequently injected scripts.
  3. Invoke Injection API: The attacker calls document.querySelector('webview').executeScript({file: 'chrome://settings/settings.js'});.
  4. URL Resolution Flaw: In the browser process, WebViewInternalExecuteCodeFunction::LoadFileForEmbedder resolves the file string 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::Resolve returns chrome://settings/settings.js as the file_url.
  5. Improper Factory Creation: WebUIURLFetcher::Start() then creates a WebUIURLLoaderFactory to 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 is chrome) instead of the embedder’s scheme (chrome-untrusted). It also passes an empty {} for allowed_hosts.
  6. 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.
  7. 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

  1. Origin Validation: In WebViewInternalExecuteCodeFunction::LoadFileForEmbedder, explicitly check that the resolved file_url shares the same origin (or at least the same scheme and host) as the embedder’s owner_base_url before allowing the fetch to proceed.
  2. Strict Factory Binding: Ensure WebUIURLFetcher instantiates the WebUIURLLoaderFactory using 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.

View on issue tracker