CVE-2026-87584
Overview
Background
- `is_renderer_initiated`
- A per-navigation flag on
NavigationParamsthat records whether a navigation was triggered by web content (renderer) versus by trusted browser code, and which gates many downstream authorization checks that browser-initiated navigations are allowed to skip. - WebUI trust policy
WebUIController::TrustPolicydistinguisheskTrustedprivileged pages served overchrome://from less-trusted pages served overchrome-untrusted://, exposed viaweb_ui->GetController()->GetTrustPolicy().- `chrome-untrusted://`
- A WebUI scheme used to host code that processes untrustworthy or web-derived input; it still has a
WebUIobject but is deliberately not granted the trust of ordinarychrome://pages. - `Referrer()`
- An empty referrer that WebUI navigations substitute so that
chrome://-style URLs (which may carry search terms or other sensitive data) are never leaked to destination sites.
Root Cause Analysis
In content/browser/renderer_host/navigator.cc, two navigation paths tested only whether the source frame had a WebUI object (render_frame_host->web_ui()), and if so unconditionally forced the navigation to be treated as browser-initiated by setting is_renderer_initiated = false. Because a chrome-untrusted:// frame also has a WebUI object, a renderer-initiated navigation started from untrusted (web-facing) WebUI content was mislabeled as browser-initiated, violating the invariant that navigations originating from untrustworthy content must remain renderer-initiated so their authorization checks apply. The is_renderer_initiated flag is precisely what many browser-side security checks rely on to decide which restrictions to enforce, and browser-initiated navigations are permitted to bypass a number of those restrictions.
The fix wraps the is_renderer_initiated = false assignment in a guard that only demotes the flag when web_ui->GetController()->GetTrustPolicy() == WebUIController::TrustPolicy::kTrusted, so only genuinely trusted chrome:// pages get browser-initiated treatment. Untrusted WebUI navigations now retain their default renderer-initiated status (is_renderer_initiated stays true), keeping the downstream authorization checks in force. This is a tightening: an over-broad “any WebUI” condition is narrowed to “trusted WebUI only.”
WebUI object” with “is trusted,” which let chrome-untrusted:// frames inherit the browser-initiated exemption meant only for privileged chrome:// pages; the fix distinguishes them by consulting GetTrustPolicy() and demotes to browser-initiated exclusively for kTrusted controllers.Attack Path
- Reach untrusted WebUI
An attacker gets code running in a
chrome-untrusted://frame, e.g. by supplying content or input that achrome-untrusted://surface renders or processes. - Trigger a renderer-initiated navigation
From that frame, script issues an ordinary navigation such as
location.href = <url>, which is renderer-initiated by nature. - Flag is misclassified
In
navigator.cc, the pre-patch code sees a non-nullweb_ui()and setsis_renderer_initiated = false, laundering the untrusted navigation into a browser-initiated one. - Bypass authorization checks
Because
is_renderer_initiatedis nowfalse, downstream checks that gate renderer-initiated navigations are skipped, so the untrusted page performs a navigation it should not have been authorized for (inferred — the specific check bypassed is not shown in the diff).
Impact Assessment
chrome-untrusted:// WebUI frame gains the ability to have its renderer-initiated navigations treated as trusted browser-initiated navigations, bypassing authorization checks that key off is_renderer_initiated in the browser process. The precondition is control of content in a chrome-untrusted:// surface. The advisory classifies this as incorrect authorization in WebUI; no exploited-in-the-wild status is stated, and any escalation beyond the mislabeling itself is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
AdditionalSchemesWebUINavigationBrowserTestcontent/browser/webui/web_ui_navigation_browsertest.cc |
modified | |
AdditionalSchemesWebUINavigationBrowserTestcontent/browser/webui/web_ui_navigation_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/navigator.cccontent/browser/webui/web_ui_navigation_browsertest.cc
Audit Directions
- Presence-of-WebUI as trust proxySearch for other sites that branch on
render_frame_host->web_ui()(or any non-nullWebUIcheck) to grant a privilege or skip a check, and confirm they instead consultGetTrustPolicy()to excludechrome-untrusted://. - `is_renderer_initiated` demotion pointsAudit every place that sets
is_renderer_initiated/params.is_renderer_initiatedtofalse, verifying the source is actually trusted browser code or akTrustedWebUI and not merely any WebUI or embedder-supplied frame. - chrome-untrusted:// capability parityReview other WebUI-gated behaviors (referrer suppression, binding grants, URL-load allowances) for the same conflation of trusted and untrusted WebUI schemes, since
chrome-untrusted://frames carry aWebUIobject but must not inheritchrome://trust.
Patch
From 140c7324157ca9e303d196e3f96568abf741962b Mon Sep 17 00:00:00 2001
From: Giovanni Ortuño Urquidi <ortuno@chromium.org>
Date: Mon, 03 Aug 2026 13:07:25 -0700
Subject: [PATCH] webui: Mark untrusted webui navigations as renderer initiated
We use is_renderer_initiated for untrustworhty navigations, so we
shouldn't set it to false for chrome-untrusted://.
Bug: 537466493
Change-Id: Ie1b10be89066be6cc9186ba8261b7c3d7271c947
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8182929
Commit-Queue: Giovanni Ortuno Urquidi <ortuno@chromium.org>
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1672853}
---
diff --git a/content/browser/renderer_host/navigator.cc b/content/browser/renderer_host/navigator.cc
index 425ad4aa..6375e2d1 100644
--- a/content/browser/renderer_host/navigator.cc
+++ b/content/browser/renderer_host/navigator.cc
@@ -48,6 +48,7 @@
#include "content/public/browser/page_navigator.h"
#include "content/public/browser/render_view_host.h"
#include "content/public/browser/restore_type.h"
+#include "content/public/browser/web_ui_controller.h"
#include "content/public/common/bindings_policy.h"
#include "content/public/common/content_client.h"
#include "content/public/common/content_constants.h"
@@ -1149,15 +1150,19 @@
params.source_render_process_id =
render_frame_host->GetProcess()->GetDeprecatedID();
- if (render_frame_host->web_ui()) {
+ if (WebUI* web_ui = render_frame_host->web_ui()) {
// Note that we hide the referrer for Web UI pages. We don't really want
// web sites to see a referrer of "chrome://blah" (and some chrome: URLs
// might have search terms or other stuff we don't want to send to the
// site), so we send no referrer.
params.referrer = Referrer();
- // Navigations in Web UI pages count as browser-initiated navigations.
- params.is_renderer_initiated = false;
+ // Navigations in trusted Web UI pages count as browser-initiated
+ // navigations.
+ if (web_ui->GetController()->GetTrustPolicy() ==
+ WebUIController::TrustPolicy::kTrusted) {
+ params.is_renderer_initiated = false;
+ }
}
params.blob_url_loader_factory = std::move(blob_url_loader_factory);
@@ -1212,15 +1217,19 @@
// navigation. See https://crbug.com/495161.
bool is_renderer_initiated = true;
Referrer referrer_to_use(referrer);
- if (render_frame_host->web_ui()) {
+ if (WebUI* web_ui = render_frame_host->web_ui()) {
// Note that we hide the referrer for Web UI pages. We don't really want
// web sites to see a referrer of "chrome://blah" (and some chrome: URLs
// might have search terms or other stuff we don't want to send to the
// site), so we send no referrer.
referrer_to_use = Referrer();
- // Navigations in Web UI pages count as browser-initiated navigations.
- is_renderer_initiated = false;
+ // Navigations in trusted Web UI pages count as browser-initiated
+ // navigations.
+ if (web_ui->GetController()->GetTrustPolicy() ==
+ WebUIController::TrustPolicy::kTrusted) {
+ is_renderer_initiated = false;
+ }
}
if (is_renderer_initiated &&
diff --git a/content/browser/webui/web_ui_navigation_browsertest.cc b/content/browser/webui/web_ui_navigation_browsertest.cc
index 0748c319..f6e1f81 100644
--- a/content/browser/webui/web_ui_navigation_browsertest.cc
+++ b/content/browser/webui/web_ui_navigation_browsertest.cc
@@ -25,6 +25,7 @@
#include "content/public/test/content_browser_test.h"
#include "content/public/test/content_browser_test_content_browser_client.h"
#include "content/public/test/content_browser_test_utils.h"
+#include "content/public/test/navigation_handle_observer.h"
#include "content/public/test/scoped_web_ui_controller_factory_registration.h"
#include "content/public/test/test_frame_navigation_observer.h"
#include "content/public/test/test_navigation_observer.h"
@@ -1225,6 +1226,46 @@
}
}
+// Verify that renderer-initiated navigations from chrome-untrusted:// frames
+// have is_renderer_initiated() == true.
+IN_PROC_BROWSER_TEST_F(WebUINavigationBrowserTest,
+ UntrustedWebUIsAreRendererInitiated) {
+ WebUIConfigMap::GetInstance().AddUntrustedWebUIConfig(
+ std::make_unique<ui::TestUntrustedWebUIConfig>("test-host"));
+
+ GURL untrusted_url1(GetChromeUntrustedUIURL("test-host/title1.html"));
+ GURL untrusted_url2(GetChromeUntrustedUIURL("test-host/title2.html"));
+
+ EXPECT_TRUE(NavigateToURL(shell(), untrusted_url1));
+
+ NavigationHandleObserver untrusted_observer(shell()->web_contents(),
+ untrusted_url2);
+ TestNavigationObserver untrusted_nav_observer(shell()->web_contents(), 1);
+ EXPECT_TRUE(
+ ExecJs(shell(), JsReplace("location.href = $1;", untrusted_url2)));
+ untrusted_nav_observer.Wait();
+ EXPECT_TRUE(untrusted_observer.has_committed());
+ EXPECT_TRUE(untrusted_observer.is_renderer_initiated());
+}
+
+// Verify that renderer-initiated navigations from trusted chrome:// frames
+// count as browser-initiated (is_renderer_initiated() == false).
+IN_PROC_BROWSER_TEST_F(WebUINavigationBrowserTest,
+ TrustedWebUIsAreBrowserInitiated) {
+ GURL trusted_url1(GetWebUIURL("web-ui/title1.html"));
+ GURL trusted_url2(GetWebUIURL("web-ui/title2.html"));
+
+ EXPECT_TRUE(NavigateToURL(shell(), trusted_url1));
+
+ NavigationHandleObserver trusted_observer(shell()->web_contents(),
+ trusted_url2);
+ TestNavigationObserver trusted_nav_observer(shell()->web_contents(), 1);
+ EXPECT_TRUE(ExecJs(shell(), JsReplace("location.href = $1;", trusted_url2)));
+ trusted_nav_observer.Wait();
+ EXPECT_TRUE(trusted_observer.has_committed());
+ EXPECT_FALSE(trusted_observer.is_renderer_initiated());
+}
+
class AdditionalSchemesWebUINavigationBrowserTest : public ContentBrowserTest {
public:
AdditionalSchemesWebUINavigationBrowserTest() {
Regression Test / PoC
diff --git a/content/browser/webui/web_ui_navigation_browsertest.cc b/content/browser/webui/web_ui_navigation_browsertest.cc
index 0748c319..f6e1f81 100644
--- a/content/browser/webui/web_ui_navigation_browsertest.cc
+++ b/content/browser/webui/web_ui_navigation_browsertest.cc
@@ -25,6 +25,7 @@
#include "content/public/test/content_browser_test.h"
#include "content/public/test/content_browser_test_content_browser_client.h"
#include "content/public/test/content_browser_test_utils.h"
+#include "content/public/test/navigation_handle_observer.h"
#include "content/public/test/scoped_web_ui_controller_factory_registration.h"
#include "content/public/test/test_frame_navigation_observer.h"
#include "content/public/test/test_navigation_observer.h"
@@ -1225,6 +1226,46 @@
}
}
+// Verify that renderer-initiated navigations from chrome-untrusted:// frames
+// have is_renderer_initiated() == true.
+IN_PROC_BROWSER_TEST_F(WebUINavigationBrowserTest,
+ UntrustedWebUIsAreRendererInitiated) {
+ WebUIConfigMap::GetInstance().AddUntrustedWebUIConfig(
+ std::make_unique<ui::TestUntrustedWebUIConfig>("test-host"));
+
+ GURL untrusted_url1(GetChromeUntrustedUIURL("test-host/title1.html"));
+ GURL untrusted_url2(GetChromeUntrustedUIURL("test-host/title2.html"));
+
+ EXPECT_TRUE(NavigateToURL(shell(), untrusted_url1));
+
+ NavigationHandleObserver untrusted_observer(shell()->web_contents(),
+ untrusted_url2);
+ TestNavigationObserver untrusted_nav_observer(shell()->web_contents(), 1);
+ EXPECT_TRUE(
+ ExecJs(shell(), JsReplace("location.href = $1;", untrusted_url2)));
+ untrusted_nav_observer.Wait();
+ EXPECT_TRUE(untrusted_observer.has_committed());
+ EXPECT_TRUE(untrusted_observer.is_renderer_initiated());
+}
+
+// Verify that renderer-initiated navigations from trusted chrome:// frames
+// count as browser-initiated (is_renderer_initiated() == false).
+IN_PROC_BROWSER_TEST_F(WebUINavigationBrowserTest,
+ TrustedWebUIsAreBrowserInitiated) {
+ GURL trusted_url1(GetWebUIURL("web-ui/title1.html"));
+ GURL trusted_url2(GetWebUIURL("web-ui/title2.html"));
+
+ EXPECT_TRUE(NavigateToURL(shell(), trusted_url1));
+
+ NavigationHandleObserver trusted_observer(shell()->web_contents(),
+ trusted_url2);
+ TestNavigationObserver trusted_nav_observer(shell()->web_contents(), 1);
+ EXPECT_TRUE(ExecJs(shell(), JsReplace("location.href = $1;", trusted_url2)));
+ trusted_nav_observer.Wait();
+ EXPECT_TRUE(trusted_observer.has_committed());
+ EXPECT_FALSE(trusted_observer.is_renderer_initiated());
+}
+
class AdditionalSchemesWebUINavigationBrowserTest : public ContentBrowserTest {
public:
AdditionalSchemesWebUINavigationBrowserTest() {
Original Bug Report
Stop treating navigations from chrome-untrusted:// as browser-initiated
Splitting this part of issue 497957278 out:
Navigator::RequestOpenURL contains a flawed check:
if (render_frame_host->web_ui()) {
// ...
// Navigations in Web UI pages count as browser-initiated navigations.
params.is_renderer_initiated = false;
}
Since the chrome-untrusted:// frame has a WebUI object, the code unconditionally sets params.is_renderer_initiated = false, turning potentially malicious renderer-initiated navigation requests into trusted browser-initiated requests.
AI-suggested fix:
Navigator::RequestOpenURL should not blindly set params.is_renderer_initiated = false for all WebUIs. It should ideally check WebUIController::GetTrustPolicy() and only launder navigations for trusted WebUIs.