Medium chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in API
DescriptionImproper input validation in API
ComponentAPI
Sub-componentLens
Bug ClassInput Validation
Tracker562237871
Fix commit6b0b52cf335b (chromium/src) +8/-268
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
chrome/browser/resources/lens/overlay/side_panel/side_panel_app.ts
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

Files Changed

  • components/lens/lens_features.h
  • extensions/common/api/_api_features.json
  • chrome/common/extensions/api/_api_features.json
  • chrome/test/data/webui/webview/BUILD.gn
  • chrome/test/data/webui/webview/webview_untrusted_test.ts
  • chrome/browser/resources/lens/overlay/side_panel/side_panel_app.ts
  • chrome/browser/ui/lens/lens_side_panel_untrusted_ui.cc
  • chrome/test/data/webui/webview/webui_webview_browsertest.cc
  • chrome/browser/resources/lens/overlay/BUILD.gn
  • chrome/browser/resources/lens/overlay/side_panel/side_panel_app.html
  • components/lens/lens_features.cc
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)
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 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);
-  });
-});
Loading diff…

Original Bug Report

reported by [email protected]

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:

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 IsAvailableToWebViewEmbedderWebUIFrame requires a chrome:// embedder (web_request_api.cc:1077) and checks the feature as trusted-WebUI context (:1082), both of which chrome-untrusted://lens fails.
  • 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

  1. Feature grant to an untrusted origin: extensions/common/api/_api_features.json:965-973 — webRequestInternal is available at stable to webui_untrusted context for chrome-untrusted://lens/* (alongside webViewInternal :1024-1032, guestViewInternal, webViewRequest). The trusted chrome:// allowlist entries these sit beside (chrome://chrome-signin, chrome://oobe, …) are the callers the WebUI carve-out below was designed for.

  2. 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 the web_view_instance_id == 0 branch — unreachable for webview listeners. For a chrome-untrusted:// caller with a nonzero instance id, nothing is authorized.

  3. 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). extraInfoSpec is parsed verbatim from renderer-supplied args (ExtraInfoSpec::InitFromValue, web_request_api_helpers.cc — pure string→flag parse, no permission coupling); only SECURITY_INFO is separately gated (web_request_api.cc:529-553).

  4. 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 by WebViewGuest::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.

  5. Cookie/Set-Cookie exposure is spec-flag-only: web_request_api_helpers.cc:1818-1830 — ShouldHideRequestHeader/ShouldHideResponseHeader hide Cookie/Set-Cookie only when EXTRA_HEADERS is 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.

  6. 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.

  7. Activation condition: web_request_api.cc:849-877 — with MayHaveProxies() 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) rejects chrome-untrusted:// embedders at :1077, leaving the listener registered but inert.

Reproduction

  1. Use a profile with one enabled extension that merely declares the webRequest permission (a manifest-only MV3 extension with no code or listeners suffices — the stand-in for any installed ad blocker or privacy tool).
  2. Obtain script execution in the chrome-untrusted://lens renderer (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).
  3. Create and attach a <webview> guest (available: guestViewInternal/webViewInternal are granted to the origin), then register webViewInternal.onBeforeSendHeaders with ["blocking","requestHeaders","extraHeaders"], onHeadersReceived with ["blocking","responseHeaders","extraHeaders"], and onBeforeRequest with ["blocking","requestBody"] via the webRequest bindings.
  4. Navigate the guest to cross-origin targets; return blocking responses (forged/injected request headers, redirects, cancels) from the listeners.
  5. 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:

  1. Cookie read (extraHeaders): captured Cookie: secret_session=S3CR3T-C00KIE-0xDEADBEEF on guest requests.
  2. Set-Cookie read (extraHeaders): captured the victim’s Set-Cookie response header.
  3. POST body read (requestBody): captured {"formData":{"password":["hunter2-P0STED-SECRET"]}}.
  4. Header write (blocking): forged Origin: https://forged.corp.internal and added X-Injected-By: chrome-untrusted-lens-poc arrived 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 (Accept removed).
  5. Traffic control (blocking): /hijack-me was redirected to /hijacked before reaching the network (/hijack-me never 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 the chrome-untrusted://lens document.
  • server.py — victim web server; logs every request’s method/path/headers/body to server-access.log.
  • proof-extension/ — MV3 extension holding only the webRequest permission, no code, no listeners. Simulates the report’s precondition (an installed ad blocker / privacy extension). Loading it is what flips MayHaveProxies() 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/Default release and out/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):

  1. 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=&lt;tmp&gt;).
  2. Opens a tab at chrome-untrusted://lens/side_panel/generic-error-icon.png — an inert image resource served by the real LensSidePanelUntrustedUI data source. This yields a genuine chrome-untrusted://lens renderer process without running the side panel’s own application JS.
  3. Uses CDP Runtime.evaluate to run lens_webrequest_poc.js in 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 same webRequestInternal IPCs directly). It:
    • creates a <webview> (available in this context because webViewInternal is granted to chrome-untrusted://lens/*),
    • registers webview.request.onBeforeSendHeaders / onHeadersReceived / onBeforeRequest listeners 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.
  4. 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-port is only the injection vehicle for the compromised-renderer simulation; --load-extension stands in for a user-installed webRequest extension (the report’s explicit activation precondition); the remaining flags are environment-only.
  • The <webview> element and webview.request API 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.

View on issue tracker