CVE-2026-113062
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/resources/lens/overlay/side_panel/side_panel_app.ts |
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 |
Files Changed
components/lens/lens_features.hextensions/common/api/_api_features.jsonchrome/common/extensions/api/_api_features.jsonchrome/test/data/webui/webview/BUILD.gnchrome/test/data/webui/webview/webview_untrusted_test.tschrome/browser/resources/lens/overlay/side_panel/side_panel_app.tschrome/browser/ui/lens/lens_side_panel_untrusted_ui.ccchrome/test/data/webui/webview/webui_webview_browsertest.ccchrome/browser/resources/lens/overlay/BUILD.gnchrome/browser/resources/lens/overlay/side_panel/side_panel_app.htmlcomponents/lens/lens_features.cc
Patch
From 6b0b52cf335b6f51e68b618599cc548296206fac Mon Sep 17 00:00:00 2001 From: Duncan Mercer <[email protected]> Date: Mon, 28 Sep 2026 15:14:27 -0700 Subject: [PATCH] [lens] Clean up experimental webview in side panel <webview> was experimentally added to the Lens side panel WebUI to host results instead of an iframe. However, the Contextual Tasks side panel was created to host <webview>, leaving the Lens side panel to use an <iframe> for results. Because chrome-untrusted://lens/* was granted internal webview and webRequest APIs in _api_features.json, this exposed unnecessary permission surface. This CL cleans up the unused <webview> implementation in the Lens side panel: - Remove <webview> from side_panel_app.html and use <iframe> directly. - Remove enableWebviewResults property, getResults() helper, and resultsWebview references from side_panel_app.ts. - Remove enableWebviewResults WebUI loadTimeData from lens_side_panel_untrusted_ui.cc. - Remove kLensSidePanelEnableWebviewResults feature flag and helper from lens_features.{h,cc}. - Remove chrome-untrusted://lens/* from _api_features.json in chrome/common/extensions/api/ and extensions/common/api/. - Remove unused typescript definitions from overlay/BUILD.gn. Fixed: 562237871 Change-Id: Ic00f6195a24a2bb81de582dad20a2bfb0088f7a4 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8445559 Commit-Queue: Duncan Mercer <[email protected]> Reviewed-by: Toni Barzic <[email protected]> Reviewed-by: Juan Mojica <[email protected]> Cr-Commit-Position: refs/heads/main@{#1706471} --- diff --git a/chrome/browser/resources/lens/overlay/side_panel/side_panel_app.html b/chrome/browser/resources/lens/overlay/side_panel/side_panel_app.html index 23cbe730..4934ba3 100644 --- a/chrome/browser/resources/lens/overlay/side_panel/side_panel_app.html +++ b/chrome/browser/resources/lens/overlay/side_panel/side_panel_app.html @@ -658,17 +658,8 @@ allow="cross-origin-isolated; display-capture; geolocation; microphone; clipboard-read; clipboard-write;" frameborder="0" - hidden$="[[enableWebviewResults]]" > </iframe> -<webview - name="resultsWebview" - id="resultsWebview" - class="results results-pane" - partition="persist:lenspart" - hidden$="[[!enableWebviewResults]]" -> -</webview> <feedback-toast id="feedbackToast"></feedback-toast> diff --git a/chrome/browser/resources/lens/overlay/side_panel/side_panel_app.ts b/chrome/browser/resources/lens/overlay/side_panel/side_panel_app.ts index bbb0fd9..4f21e58f 100644 --- a/chrome/browser/resources/lens/overlay/side_panel/side_panel_app.ts +++ b/chrome/browser/resources/lens/overlay/side_panel/side_panel_app.ts @@ -61,7 +61,6 @@ messageToastDismissButton: CrButtonElement, errorPage: SidePanelErrorPageElement, results: HTMLIFrameElement, - resultsWebview: chrome.webviewTag.WebView, searchbox: LensSearchboxElement, searchboxContainer: HTMLElement, searchboxGhostLoader: SearchboxGhostLoaderElement, @@ -101,10 +100,6 @@ type: Boolean, value: () => loadTimeData.getBoolean('enableClientSideAimHeader'), }, - enableWebviewResults: { - type: Boolean, - value: () => loadTimeData.getBoolean('enableWebviewResults'), - }, enableLensAimSuggestions: { reflectToAttribute: true, type: Boolean, @@ -136,7 +131,8 @@ value: false, reflectToAttribute: true, }, - /* Used to decide whether to show back arrow onFocusOut in searchbox. */ + /* Used to decide whether to show back arrow onFocusOut in + searchbox. */ wasBackArrowAvailable: { type: Boolean, value: false, @@ -201,7 +197,8 @@ }, /* TODO(385183449): Once WebUI preloading is implemented in the * side panel, update the loadTimeData for searchBoxHint in the side - * panel WebUI constructor insteading of passing it to the searchbox. */ + * panel WebUI constructor insteading of passing it to the + * searchbox. */ placeholderText: { type: String, computed: @@ -289,8 +286,6 @@ declare private enableFloatingGForHeader: boolean; // Whether the client side header is enabled via feature flag. declare private enableClientSideAimHeader: boolean; - // Whether the webview results container is enabled via feature flag. - declare private enableWebviewResults: boolean; declare private enableLensAimSuggestions: boolean; // Whether the gradient background for AIM suggestions is enabled via feature // flag. @@ -423,7 +418,7 @@ // Start listening to postMessages on the window. this.postMessageReceiver = new PostMessageReceiver( - SidePanelBrowserProxyImpl.getInstance(), this.getResults()); + SidePanelBrowserProxyImpl.getInstance(), this.$.results); // If the composebox is enabled, start listening to resize events to update // the composebox height. @@ -540,7 +535,7 @@ private loadResultsInFrame(resultsUrl: Url) { const url = new URL(resultsUrl); - const resultsBoundingRect = this.getResults().getBoundingClientRect(); + const resultsBoundingRect = this.$.results.getBoundingClientRect(); if (resultsBoundingRect.width > 0) { url.searchParams.set( VIEWPORT_WIDTH_KEY, resultsBoundingRect.width.toString()); @@ -552,7 +547,7 @@ // The src needs to be reset explicitly every time this function is called // to force a reload. We cannot get the currently displayed URL from the // frame because of cross-origin restrictions. - this.getResults().src = url.href; + this.$.results.src = url.href; // Remove focus from the input when results are loaded. Does not have // any effect if input is not focused. this.blurSearchbox(); @@ -740,7 +735,7 @@ this.hideAndReshowFeedbackToast(); } - this.getResults().focus(); + this.$.results.focus(); } private focusSearchbox() { @@ -775,15 +770,6 @@ } } - // Returns the container housing the results. Can either be the results - // iframe or webview. - private getResults(): HTMLIFrameElement|chrome.webviewTag.WebView { - if (this.enableWebviewResults) { - return this.$.resultsWebview; - } - return this.$.results; - } - private hideAndReshowFeedbackToast() { // Cancel the timeout to show the feedback toast if it is set. clearTimeout(this.feedbackToastShowAfterDelayTimeoutId); diff --git a/chrome/browser/ui/lens/lens_side_panel_untrusted_ui.cc b/chrome/browser/ui/lens/lens_side_panel_untrusted_ui.cc index a523928..2cf6365 100644 --- a/chrome/browser/ui/lens/lens_side_panel_untrusted_ui.cc +++ b/chrome/browser/ui/lens/lens_side_panel_untrusted_ui.cc @@ -92,9 +92,6 @@ html_source->AddBoolean( "enableSummarizeSuggestionHint", lens::features::ShouldEnableSummarizeHintForContextualSuggest()); - html_source->AddBoolean( - "enableWebviewResults", - lens::features::IsLensSidePanelWebviewResultsEnabled()); html_source->AddBoolean("enableLensAimSuggestions", lens::features::GetAimSuggestionsEnabled()); html_source->AddBoolean( diff --git a/chrome/common/extensions/api/_api_features.json b/chrome/common/extensions/api/_api_features.json index 2717913..0d0d4f8 100644 --- a/chrome/common/extensions/api/_api_features.json +++ b/chrome/common/extensions/api/_api_features.json @@ -269,15 +269,6 @@ "chrome://lock-reauth/*", "chrome://skills/*" ] - }, { - "internal": true, - "channel": "stable", - "contexts": [ - "webui_untrusted" - ], - "matches": [ - "chrome-untrusted://lens/*" - ] }], "chromeWebViewInternal": [{ "internal": true, @@ -292,15 +283,6 @@ // be extra-cautious, we restrict to the chrome scheme. "matches": [ "chrome://*/*" ] }, { - "internal": true, - "channel": "stable", - "contexts": [ - "webui_untrusted" - ], - "matches": [ - "chrome-untrusted://lens/*" - ] - }, { // This feature is used by the Controlled Frame API and is specifically // enabled by //chrome. "internal": true, diff --git a/components/lens/lens_features.cc b/components/lens/lens_features.cc index 577519555..90fc5bb 100644 --- a/components/lens/lens_features.cc +++ b/components/lens/lens_features.cc @@ -107,9 +107,6 @@ BASE_FEATURE(kLensOverlayForceEmptyCsbQuery, base::FEATURE_DISABLED_BY_DEFAULT); -BASE_FEATURE(kLensSidePanelEnableWebviewResults, - base::FEATURE_DISABLED_BY_DEFAULT); - BASE_FEATURE(kLensAimSuggestions, base::FEATURE_DISABLED_BY_DEFAULT); BASE_FEATURE(kLensAimSuggestionsGradientBackground, @@ -1260,11 +1257,6 @@ return base::FeatureList::IsEnabled(kLensOverlayForceEmptyCsbQuery); } -bool IsLensSidePanelWebviewResultsEnabled() { - return base::FeatureList::IsEnabled(kLensSidePanelEnableWebviewResults); -} - - bool IsLensVideoCitationsEnabled() { return base::FeatureList::IsEnabled(kLensVideoCitations); } diff --git a/components/lens/lens_features.h b/components/lens/lens_features.h index b068082..07fca8263 100644 --- a/components/lens/lens_features.h +++ b/components/lens/lens_features.h @@ -149,10 +149,6 @@ COMPONENT_EXPORT(LENS_FEATURES) BASE_DECLARE_FEATURE(kLensOverlayForceEmptyCsbQuery); -// Enables using a webview for the results frame instead of an iframe. -COMPONENT_EXPORT(LENS_FEATURES) -BASE_DECLARE_FEATURE(kLensSidePanelEnableWebviewResults); - // Enables AIM suggestions in the composebox. COMPONENT_EXPORT(LENS_FEATURES) BASE_DECLARE_FEATURE(kLensAimSuggestions); @@ -1056,11 +1052,6 @@ COMPONENT_EXPORT(LENS_FEATURES) extern bool IsLensOverlayForceEmptyCsbQueryEnabled(); -// Whether to use a webview for the results frame instead of an iframe. -COMPONENT_EXPORT(LENS_FEATURES) -extern bool IsLensSidePanelWebviewResultsEnabled(); - - // Whether the feature to enable the special handling for video citations is // enabled. COMPONENT_EXPORT(LENS_FEATURES) diff --git a/extensions/common/api/_api_features.json b/extensions/common/api/_api_features.json index 740ee61..0721762e 100644 --- a/extensions/common/api/_api_features.json +++ b/extensions/common/api/_api_features.json @@ -281,17 +281,6 @@ "chrome://skills/*" ] }, { - "internal": true, - "channel": "stable", - "contexts": [ - "webui_untrusted" - ], - "matches": [ - "chrome-untrusted://lens/*" - ], - // "desktop_android" is not supported. - "platforms": ["chromeos", "linux", "mac", "win"] - }, { // This feature is used by the Controlled Frame API and is specifically // enabled by //chrome. "internal": true, @@ -963,15 +952,6 @@ "chrome://skills/*" ] }, { - "internal": true, - "channel": "stable", - "contexts": [ - "webui_untrusted" - ], - "matches": [ - "chrome-untrusted://lens/*" - ] - }, { // This feature is used by the Controlled Frame API and is specifically // enabled by //chrome. "internal": true, @@ -1022,15 +1002,6 @@ "chrome://skills/*" ] }, { - "internal": true, - "channel": "stable", - "contexts": [ - "webui_untrusted" - ], - "matches": [ - "chrome-untrusted://lens/*" - ] - }, { // This feature is used by the Controlled Frame API and is specifically // enabled by //chrome. "internal": true, @@ -1062,13 +1033,5 @@ "chrome://parent-access/*", "chrome://skills/*" ] - }, { - "channel": "stable", - "contexts": [ - "webui_untrusted" - ], - "matches": [ - "chrome-untrusted://lens/*" - ] }] } diff --git a/chrome/test/data/webui/webview/webui_webview_browsertest.cc b/chrome/test/data/webui/webview/webui_webview_browsertest.cc index 0993a424..599fcd4 100644 --- a/chrome/test/data/webui/webview/webui_webview_browsertest.cc +++ b/chrome/test/data/webui/webview/webui_webview_browsertest.cc @@ -30,12 +30,10 @@ #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/download/public/common/download_item.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/download_manager.h" #include "content/public/browser/render_view_host.h" #include "content/public/browser/render_widget_host.h" #include "content/public/browser/web_contents.h" @@ -172,30 +170,6 @@ : 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 { @@ -533,66 +507,4 @@ 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(); ... (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 7f422d6..afbab66 100644
--- a/chrome/test/data/webui/webview/BUILD.gn
+++ b/chrome/test/data/webui/webview/BUILD.gn
@@ -16,13 +16,8 @@
"//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 0993a424..599fcd4 100644
--- a/chrome/test/data/webui/webview/webui_webview_browsertest.cc
+++ b/chrome/test/data/webui/webview/webui_webview_browsertest.cc
@@ -30,12 +30,10 @@
#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/download/public/common/download_item.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/download_manager.h"
#include "content/public/browser/render_view_host.h"
#include "content/public/browser/render_widget_host.h"
#include "content/public/browser/web_contents.h"
@@ -172,30 +170,6 @@
: 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 {
@@ -533,66 +507,4 @@
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();
- }
-
- GURL GetTestUrl(const std::string& path) const {
- return embedded_test_server()->base_url().Resolve(path);
- }
-
- 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));
-}
-
-IN_PROC_BROWSER_TEST_F(UntrustedWebUIWebViewBrowserTest,
- PermissionRequestAutoDeny) {
- EXPECT_TRUE(RunTestOnWebContents(
- GetWebContentsForTesting(), "webview/webview_untrusted_test.js",
- base::StringPrintf("window.downloadUrl = '%s'; "
- "runMochaTest('WebviewUntrustedBasicTest', "
- "'PermissionRequestAutoDenyInUntrusted');",
- GetTestUrl("download-test3.gif").spec().c_str()),
- true));
-
- content::DownloadManager::DownloadVector downloads;
- browser()->GetProfile()->GetDownloadManager()->GetAllDownloads(&downloads);
- EXPECT_TRUE(downloads.empty());
- // If the test fails and a download was started, cancel it so that an
- // in-progress download prompt does not block browser shutdown during test
- // teardown.
- for (download::DownloadItem* download : downloads) {
- download->Cancel(false);
- }
-}
-
#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
deleted file mode 100644
index 94d892f..0000000
--- a/chrome/test/data/webui/webview/webview_untrusted_test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-// 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 {assertFalse, 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;
- }
-
- function getDownloadUrl(): string {
- return (window as unknown as Window & {downloadUrl: string}).downloadUrl;
- }
-
- 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');
- });
- assertThrows(() => {
- webview.find('test', {}, () => {});
- });
- });
-
- test('PermissionRequestAutoDenyInUntrusted', async () => {
- const webview = createWebview();
- let receivedPermissionRequest = false;
- webview.addEventListener('permissionrequest', event => {
- receivedPermissionRequest = true;
- const e = event as chrome.webviewTag.PermissionRequestEvent;
- e.request.allow();
- });
-
- await new Promise(resolve => {
- webview.src = getDownloadUrl();
- webview.addEventListener('loadstop', resolve);
- });
-
- assertFalse(receivedPermissionRequest);
- });
-});
Original Bug Report
WebRequest permission bypass from chrome-untrusted://lens on guest traffic
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: chrome-untrusted://lens — a scheme Chromium’s documentation requires to be treated as a compromised ordinary renderer — is granted the internal webRequestInternal/webViewInternal/guestViewInternal extension APIs, and both browser-side webRequest listener-registration paths skip every permission gate (webview, blocking, host permissions) for webview-scoped listeners when the caller is not an extension. A compromised Lens side-panel renderer can therefore register blocking + extraHeaders + requestBody listeners on a guest <webview> it creates (or on the legitimate Lens results webview in the same process). Whenever any enabled extension in the profile merely holds a webRequest-family permission (e.g. any ad blocker), all guest URLLoaderFactories are proxied and the listener goes live: the untrusted renderer reads Cookie/Set-Cookie headers and POST bodies of cross-origin guest traffic and injects/rewrites/cancels requests, with the modifications executed by the browser-side proxy. Demonstrated end-to-end on unmodified release and ASan builds.
Affected files:
extensions/browser/api/web_request/web_request_api.cc:555-617— modern registration path (WebRequestAPI::OnListenerAdded): all permission gates skipped for non-extension webview listenersextensions/browser/api/web_request/web_request_api.cc:1384-1432— legacy path (WebRequestInternalAddEventListenerFunction::Run): identical gate structureextensions/browser/api/web_request/extension_web_request_event_router.cc:3070-3077— dispatch matches webview listeners purely on (embedder pid, instance id); per-URL permission check skipped at:3110extensions/common/api/_api_features.json:965-973—webRequestInternalgranted tochrome-untrusted://lens/*on stable; companion grants forwebViewInternal(:1024-1032),guestViewInternal,webViewRequest
Estimated timestamp from git blame: 2025-08-29
Summary
Per docs/webui/chrome_untrusted.md, a chrome-untrusted:// page “should be assumed to be compromised, much like an ordinary renderer process”, and the design invariant is that a compromised page is “no worse than an ordinary web page”. The feature grants above expose the internal webRequest/webview API surface to chrome-untrusted://lens/* at stable, and the browser-side permission model has a WebUI carve-out that predates untrusted WebUI: for webview-scoped listeners, every check is conditioned on extension != nullptr, so a non-extension caller is never authorized for anything — not the webview permission, not webRequestBlocking, not host permissions — and the renderer-supplied extraInfoSpec (blocking, extraHeaders, requestBody, …) is accepted verbatim.
The bug is conditional on webRequest URLLoaderFactory proxying being active for the profile:
- Lens-owned guests are never proxied on their own: the WebUI fallback predicate
IsAvailableToWebViewEmbedderWebUIFramerequires achrome://embedder (web_request_api.cc:1077) and checks the feature as trusted-WebUI context (:1082), both of whichchrome-untrusted://lensfails. - But if any enabled extension in the profile merely holds a webRequest/declarativeWebRequest/webview permission — no listeners or code needed (
OnExtensionLoaded,web_request_api.cc:1164-1183→MayHaveProxies(),:1013-1023) — all factories in the profile are proxied (:849-877), including lens-guest factories, and the untrusted listener is fully live with blocking semantics.
When active, this gives a compromised lens renderer a browser-process-executed interception position over guest traffic it steers: read of Cookie/Set-Cookie/raw headers and POST bodies, injection of forbidden headers (forged Origin/Referer, arbitrary custom headers) on cross-origin requests, and cancel/redirect control. These capabilities are exactly what the network service’s CORS/forbidden-header enforcement denies to renderer-held URLLoaderFactories; the webRequest proxy applies them browser-side beneath that enforcement. Only the genuine lens process can reach this surface (the extension-function dispatcher binds the webui_untrusted context to the frame’s committed chrome-untrusted://lens URL), which narrows the premise but is not a mitigation once that process is compromised.
Root Cause Analysis
-
Feature grant to an untrusted origin:
extensions/common/api/_api_features.json:965-973—webRequestInternalis available at stable towebui_untrustedcontext forchrome-untrusted://lens/*(alongsidewebViewInternal:1024-1032,guestViewInternal,webViewRequest). The trustedchrome://allowlist entries these sit beside (chrome://chrome-signin, chrome://oobe, …) are the callers the WebUI carve-out below was designed for. -
Registration gates only run for extensions (modern path):
extensions/browser/api/web_request/web_request_api.cc:555-617—// web_request_api.cc:555-573 if (web_view_instance_id) { // If a web view ID has been supplied and the call is from an extension // (i.e. not from WebUI), we require the extension to have the webview // permission. if (extension && !extension->permissions_data()->HasAPIPermission( // <-- skipped: extension == nullptr mojom::APIPermissionID::kWebView)) { ... return; } if (!event_name.starts_with("webViewInternal.")) { ... return; } // <-- only surviving check } else { auto has_blocking_permission = [&extension, &event_name]() { DCHECK(extension); // <-- blocking gate lives only in this branch ...The blocking-permission gate (
:589-596) and host-permission gate (:604-616) are exclusively in theweb_view_instance_id == 0branch — unreachable for webview listeners. For achrome-untrusted://caller with a nonzero instance id, nothing is authorized. -
Legacy path identical:
web_request_api.cc:1384-1432(WebRequestInternalAddEventListenerFunction::Run) has the same structure (extension &&guard at:1388, blocking/host gates only in the else branch).extraInfoSpecis parsed verbatim from renderer-supplied args (ExtraInfoSpec::InitFromValue,web_request_api_helpers.cc— pure string→flag parse, no permission coupling); onlySECURITY_INFOis separately gated (web_request_api.cc:529-553). -
Dispatch skips the per-URL permission check for webviews:
extensions/browser/api/web_request/extension_web_request_event_router.cc—// extension_web_request_event_router.cc:3070-3077, 3110 if (request.is_web_view) { if (listener.id.render_process_id != request.web_view_embedder_process_id || listener.id.web_view_instance_id != request.web_view_instance_id) { return false; } } ... if (!request.is_web_view) { // <-- CanExtensionAccessURL skipped for webview requests PermissionsData::PageAccess access = WebRequestPermissions::CanExtensionAccessURL(...);Matched listeners contribute their full spec:
*extra_info_spec_out |= listener->extra_info_spec(:3157). The (embedder pid, instance id) mapping is installed byWebViewGuest::DidInitialize→PushWebViewStateToIOThread(extensions/browser/guest_view/web_view/web_view_guest.cc:568-585) before the first navigation, so interception covers the guest’s first request. -
Cookie/Set-Cookie exposure is spec-flag-only:
web_request_api_helpers.cc:1818-1830—ShouldHideRequestHeader/ShouldHideResponseHeaderhide Cookie/Set-Cookie only whenEXTRA_HEADERSis absent from the spec; there is no caller-identity input. Since the spec was accepted unchecked in step 2/3, full cookies and POST bodies reach the untrusted renderer. -
Blocking responses applied without authorization:
web_request_api.cc:1480-1644(WebRequestInternalEventHandledFunction::Run) performs only syntactic validation (IsValidHeaderName/Value) on cancel/redirectUrl/requestHeaders/responseHeaders and routes by (event, request id, source pid, instance id) — matching the untrusted listener. Header rewrites, cancels, and redirects are then executed by the browser-side proxy. -
Activation condition:
web_request_api.cc:849-877— withMayHaveProxies()true (any enabled extension holding webRequest-family permissions,:1013-1023,:1164-1183), all factories are proxied for the profile; otherwise the WebUI fallback (IsAvailableToWebViewEmbedderWebUIFrame,:1061-1089) rejectschrome-untrusted://embedders at:1077, leaving the listener registered but inert.
Reproduction
- Use a profile with one enabled extension that merely declares the
webRequestpermission (a manifest-only MV3 extension with no code or listeners suffices — the stand-in for any installed ad blocker or privacy tool). - Obtain script execution in the
chrome-untrusted://lensrenderer (the demonstration injected ordinary page-world JS into the real lens origin, which is strictly weaker than the compromised-renderer premise the scheme is documented to assume). - Create and attach a
<webview>guest (available:guestViewInternal/webViewInternalare granted to the origin), then registerwebViewInternal.onBeforeSendHeaderswith["blocking","requestHeaders","extraHeaders"],onHeadersReceivedwith["blocking","responseHeaders","extraHeaders"], andonBeforeRequestwith["blocking","requestBody"]via the webRequest bindings. - Navigate the guest to cross-origin targets; return blocking responses (forged/injected request headers, redirects, cancels) from the listeners.
- Expected: a correct implementation rejects step 3 — the caller holds neither the webview permission, nor
webRequestBlocking, nor any host permissions.
Observed (executed on unmodified release and ASan builds, identical results): all listeners were accepted with no permission errors, and with the permission-holding extension loaded the listener received every guest event — the Cookie header on all 8 cookie-bearing requests and the Set-Cookie response header via extraHeaders, and the POST body (a form password field) via requestBody. Blocking semantics were honored by the browser process: a forged Origin plus an attacker-added header arrived on the victim server’s wire on 9/9 requests (cookie injection and header deletion were additionally confirmed in the netlog), and a targeted URL was redirected before ever reaching the network. A control run without the extension confirmed the activation condition: identical registrations succeed but zero events are delivered and the redirect is inert.
Severity
medium — The demonstrated capability is nominally in the high class (cross-origin data leak plus request tampering from a compromised renderer, executed by the browser process: Cookie/Set-Cookie/POST-body read, forbidden-header injection such as forged Origin enabling CSRF against header-trusting endpoints from the victim’s network position, cancel/redirect control). Three mitigating factors together support medium per severity-guidelines: (1) an external activation precondition — an unrelated enabled extension holding webRequest/declarativeWebRequest/webview permissions must be present (common on desktop, not guaranteed; without it the listener is inert); (2) the surface is bound to the genuine lens process, which runs only Google-shipped JS, so the premise requires a lens-specific renderer compromise rather than a generic one; (3) header/cookie reads are confined to lens-guest storage-partition traffic the attacker steers (including the legitimate results webview’s session when that feature path is active), not the user’s default-profile cookie jar. Note the conditionality is fragile: if chrome-untrusted:// embedders are ever added to the proxy predicate (web_request_api.cc:1077 is currently chrome://-only) without closing the permission exemption, the bug becomes unconditional.
Suggested Fix Direction
In both registration paths, authorize non-extension callers in the webview branch instead of exempting them: require the calling frame to be trusted (chrome://) WebUI matching the existing allowlist consumers, or explicitly reject blocking/extraHeaders/requestBody specs for webui_untrusted contexts — and re-evaluate whether chrome-untrusted://lens needs webRequestInternal at all. Two adjacent notes for the same fix: patching only the proxy predicate (IsAvailableToWebViewEmbedderWebUIFrame) would convert this bug from conditional to unconditional, and the legacy else-branch dereferences extension without a null check (web_request_api.cc:1398, :1423), so a WebUI caller passing webViewInstanceId == 0 today crashes the browser process (null dereference, DoS-only) — the fix should handle the null-extension caller in that branch too.
POC: webRequestInternal webview listener registration from chrome-untrusted://lens bypasses all permission gates (blocking + extraHeaders)
Strategy: code-injection (JS injected into the real chrome-untrusted://lens WebUI renderer via CDP to simulate the compromised-renderer premise; zero Chromium source modification)
Bug Type: missing authorization (CWE-862) — browser-side permission-gate bypass, no memory corruption
What this demonstrates
chrome-untrusted://lens is granted webRequestInternal / webViewInternal /
guestViewInternal (extensions/common/api/_api_features.json). In both
browser-side listener-registration paths
(WebRequestAPI::OnListenerAdded, extensions/browser/api/web_request/web_request_api.cc:555-617,
and WebRequestInternalAddEventListenerFunction::Run, :1384-1432) every
permission check for webview-scoped listeners is conditioned on
extension != nullptr. A non-extension (WebUI) caller with a nonzero
webViewInstanceId is therefore never checked: not for the webview
permission, not for the blocking permission, not for host permissions — and
the renderer-supplied extraInfoSpec (blocking, extraHeaders,
requestBody, …) is accepted verbatim. At dispatch time, webview listeners
are matched purely on (embedder process id, instance id) and the per-URL
permission check is skipped (extension_web_request_event_router.cc,
ListenerMatchesRequest).
Once webRequest URLLoaderFactory proxying is active for the profile — which
happens as soon as any enabled extension merely holds a
webRequest-family permission (WebRequestAPI::OnExtensionLoaded →
MayHaveProxies()), e.g. any ad blocker or privacy tool — a compromised
chrome-untrusted://lens renderer gets a browser-process-executed MITM
position over guest traffic it steers. This run demonstrates, against an
unmodified Chromium binary:
- Cookie read (extraHeaders): captured
Cookie: secret_session=S3CR3T-C00KIE-0xDEADBEEFon guest requests. - Set-Cookie read (extraHeaders): captured the victim’s
Set-Cookieresponse header. - POST body read (requestBody): captured
{"formData":{"password":["hunter2-P0STED-SECRET"]}}. - Header write (blocking): forged
Origin: https://forged.corp.internaland addedX-Injected-By: chrome-untrusted-lens-pocarrived on the victim’s wire on 9/9 requests (added headers arrive lowercased). A separate diagnostic also confirmed attacker cookie injection (Cookie: attacker_cookie=INJECTED) and header deletion (Acceptremoved). - Traffic control (blocking):
/hijack-mewas redirected to/hijackedbefore reaching the network (/hijack-menever appears in the server log).
A control run without the extension shows the activation condition: the
same listeners register without error but receive zero events and the
redirect is inert (/hijack-me gets served) — see control-run.log.
Files
run_poc.py— one-command driver (starts victim server, launches Chromium, injects payload, collects evidence).lens_webrequest_poc.js— the attack payload evaluated inside thechrome-untrusted://lensdocument.server.py— victim web server; logs every request’s method/path/headers/body toserver-access.log.proof-extension/— MV3 extension holding only thewebRequestpermission, no code, no listeners. Simulates the report’s precondition (an installed ad blocker / privacy extension). Loading it is what flipsMayHaveProxies()to true.poc-output.log— full evidence from the main run (all 28 captured events with header dumps + summary).server-access.log— victim-side wire log of the main run (shows injected/forged headers arriving).control-run.log— run without the extension: listeners registered, zero events delivered.
Prerequisites
- Linux desktop Chromium build (verified on both
out/Defaultrelease andout/asan; identical results — this is a logic bug, no sanitizer report expected). - A real X display (run headful).
- Python 3 (stdlib only).
Reproduction
cd /poc-artifacts
# Main run (release build):
python3 run_poc.py --chrome /chromium_src/src/out/Default/chrome
# Control run (no webRequest-holding extension -> no proxying -> no events):
python3 run_poc.py --chrome /chromium_src/src/out/Default/chrome --no-extension
# Also reproduces identically on the ASAN build:
python3 run_poc.py --chrome /chromium_src/src/out/asan/chrome
What the driver does (see source for details):
- Launches Chromium with
--load-extension=proof-extension --remote-debugging-port=9222(plus environment flags--no-first-run --no-default-browser-check --ignore-gpu-blocklist --no-proxy-server --user-data-dir=<tmp>). - Opens a tab at
chrome-untrusted://lens/side_panel/generic-error-icon.png— an inert image resource served by the realLensSidePanelUntrustedUIdata source. This yields a genuinechrome-untrusted://lensrenderer process without running the side panel’s own application JS. - Uses CDP
Runtime.evaluateto runlens_webrequest_poc.jsin that document. The payload is plain page-world JavaScript — strictly less powerful than the compromised-lens-renderer attacker of the report (a compromised renderer could send the samewebRequestInternalIPCs directly). It:- creates a
<webview>(available in this context becausewebViewInternalis granted tochrome-untrusted://lens/*), - registers
webview.request.onBeforeSendHeaders/onHeadersReceived/onBeforeRequestlisteners with["blocking","requestHeaders","extraHeaders"],["blocking","responseHeaders","extraHeaders"],["blocking","requestBody"]— all accepted by the browser with no permission check, - steers the guest through the victim site and records everything.
- creates a
- Reads results back and writes
poc-output.log.
Fidelity notes
- No security gate was disabled. The binary, feature flags, and all
browser-side checks are stock.
--remote-debugging-portis only the injection vehicle for the compromised-renderer simulation;--load-extensionstands in for a user-installed webRequest extension (the report’s explicit activation precondition); the remaining flags are environment-only. - The
<webview>element andwebview.requestAPI are provided by Chromium’s own bindings to this context (probe in the logs:ChromeWebViewElement,hasRequest: true) — i.e. the entire attack surface is reachable from ordinary page JavaScript in the lens origin, no renderer exploit primitives are required beyond running script there. - Blocking semantics are honored by the browser process: the header rewrites and the redirect are applied by the webRequest proxying URLLoaderFactory in the browser, beneath the renderer-facing header safety enforcement of the network service.
Expected Result
run_poc.py prints a summary like the one below; poc-output.log holds the
full event dumps and server-access.log the wire-side proof.
Output
Main run (out/Default, identical on out/asan) — summary printed by run_poc.py:
{
"total_events": 28,
"cookie_read": [
{"url": "http://127.0.0.1:8000/echo",
"cookie": "secret_session=S3CR3T-C00KIE-0xDEADBEEF"},
... (8 requests total) ...
],
"set_cookie_read": [
{"url": "http://127.0.0.1:8000/setcookie",
"set_cookie": "secret_session=S3CR3T-C00KIE-0xDEADBEEF; Path=/"}
],
"post_body_read": [
{"url": "http://127.0.0.1:8000/post",
"body": {"formData": {"password": ["hunter2-P0STED-SECRET"]}}}
],
"redirected": ["http://127.0.0.1:8000/hijacked"],
"wire_evidence": {
"requests_seen": 9,
"x_injected_by_on_wire": 9,
"forged_origin_on_wire": 9,
"hijack_me_ever_served": false
}
}
Victim server wire log (server-access.log) excerpt — browser-process-applied
header forgery arriving on the network:
POST /post | origin: https://forged.corp.internal | x-injected-by: chrome-untrusted-lens-poc | body: password=hunter2-P0STED-SECRET
GET /hijacked | origin: https://forged.corp.internal | x-injected-by: chrome-untrusted-lens-poc
(/hijack-me never appears: cancelled/redirected before the network)
Control run without the extension (control-run.log):
"total_events": 0,
"wire_evidence": {"requests_seen": 9, "x_injected_by_on_wire": 0,
"forged_origin_on_wire": 0, "hijack_me_ever_served": true}
Suggested fix direction
The webview branch of both registration paths must authorize non-extension
callers instead of exempting them: for web_view_instance_id != 0 && !extension, require the calling frame to be trusted WebUI
(chrome:// scheme, matching the existing allowlist consumers such as
chrome://chrome-signin and chrome://oobe), or explicitly reject
blocking/extraHeaders/requestBody specs for webui_untrusted
contexts. Merely fixing the proxy-decision predicate
(IsAvailableToWebViewEmbedderWebUIFrame is currently chrome://-only)
without closing this exemption would convert the bug from conditional to
unconditional.
Attached file hierarchy:
The following files were flattened for attachment. Double-underscores (__) in filenames represent directory separators.
proof-extension__manifest.json→proof-extension/manifest.json
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.
- http://127.0.0.1:8000/echo
- http://127.0.0.1:8000/hijacked
- http://127.0.0.1:8000/post
- http://127.0.0.1:8000/setcookie
- https://forged.corp.internal
- https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/api/web_request/extension_web_request_event_router.cc
- https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/api/web_request/extension_web_request_event_router.cc;l=3070
- https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/api/web_request/web_request_api.cc;l=1384
- https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/api/web_request/web_request_api.cc;l=555
- https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/guest_view/web_view/web_view_guest.cc;l=568