CVE-2026-95297
Overview
Background
- Contextual Tasks
- a Chrome feature that hosts Google-provided task UI inside a WebUI page (
chrome://contextual-tasks) using an embedded<webview>/guest to render remote and untrusted content. - `ContextualTasksProxyingURLLoaderFactory`
- the proxying
network::mojom::URLLoaderFactoryimplementation whose interceptor attaches primary-account OAuth Bearer tokens to outgoing requests bound for trusted Google endpoints. - `request_initiator`
- the origin that initiated a
network::ResourceRequest, present for content-initiated requests (e.g. subframefetch) and absent (nullopt) for browser-initiated navigations. - Ambient credential injection
- a class of confused-deputy bug where a privileged proxy attaches the user’s credentials to requests it should not, letting untrusted code borrow the user’s identity.
Root Cause Analysis
In contextual_tasks_url_loader_factory_interceptor.cc, the CreateLoaderAndStart path decided whether to fetch and attach a primary-account OAuth Authorization header based only on the request destination, checking ShouldAddAuthHeader against the target URL while ignoring who initiated the request. This violated the invariant that ambient credentials must only be attached to requests originating from trusted origins: an untrusted cross-origin subframe (for example a data: origin) inside the contextual tasks guest could issue a fetch to a trusted Google endpoint and have the user’s Bearer token silently injected.
The fix adds an initiator check so that when modified_request.request_initiator.has_value() and ShouldAddAuthHeader(modified_request.request_initiator->GetURL()) returns false, the request is forwarded verbatim via target_factory_->CreateLoaderAndStart without requesting an access token. Browser-initiated navigations (no initiator) are deliberately still allowed to receive the token, preserving legitimate behavior while closing the confused-deputy gap.
ShouldAddAuthHeader before the OAuth token is attached.Attack Path
- Load contextual tasks UI
The user opens
chrome://contextual-tasks, which embeds a<webview>guest whose network requests flow throughContextualTasksProxyingURLLoaderFactory. - Introduce untrusted content
An attacker gets an untrusted cross-origin document (e.g. a
data:origin, as exercised by the test’sdata:text/htmlnavigation) loaded inside the guest. - Issue cross-origin request to trusted endpoint
The untrusted context calls
fetchtoward a trusted Google host, so the destination passesShouldAddAuthHeadereven though the initiator is untrusted. - Token injected by confused deputy
Because the pre-patch code only checked the destination, the interceptor requests the primary-account access token and attaches it as an
AuthorizationBearer header to the untrusted-initiated request. - Credential abuse The attacker-controlled context receives or drives requests carrying the user’s OAuth token, gaining ambient access to trusted Google endpoints as the user (inferred).
Impact Assessment
data: origin subframe) inside the contextual tasks <webview>. The advisory classifies this as missing authorization (LogicError) in Contextual Tasks and states no exploited-in-the-wild status; any escalation to reading account-scoped data beyond token attachment is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor_browsertest.cc |
modified |
Files Changed
chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor_browsertest.ccchrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor.cc
Audit Directions
- Destination-only credential checksAudit every proxying
URLLoaderFactoryand header-injecting interceptor that decides to attach cookies,Authorization, or user-agent headers based solely on the target URL, and confirm they also validaterequest_initiator. - Guest/webview initiator trustReview features that embed
<webview>/guest content and inject ambient credentials, checking that untrusted or cross-origin initiators (includingdata:,blob:, and sandboxed origins) cannot borrow primary-account tokens. - `nullopt` initiator handlingExamine code that treats a missing
request_initiatoras trusted (browser-initiated), ensuring untrusted content cannot forge or omit the initiator to bypass the origin allowlist.
Patch
From 17f66f8a862a195b0334e3c1b7c06cc0b4dbd080 Mon Sep 17 00:00:00 2001 From: Jason Hu <[email protected]> Date: Wed, 02 Sep 2026 16:34:48 -0700 Subject: [PATCH] [context-input] Check request initiator before injecting OAuth token Verify the request initiator origin in ContextualTasksProxyingURLLoaderFactory before attaching primary account OAuth Bearer tokens. This prevents ambient credential injection when untrusted cross-origin subframes inside the contextual tasks guest issue requests to trusted Google endpoints. Bug: b:501648493 Change-Id: Ifbb4f902556e7ea90faaf9feea38bfef3d565c6a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8343472 Commit-Queue: Jason Hu <[email protected]> Reviewed-by: Duncan Mercer <[email protected]> Cr-Commit-Position: refs/heads/main@{#1691186} --- diff --git a/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor.cc b/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor.cc index d19d23a..eb53dab 100644 --- a/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor.cc +++ b/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor.cc @@ -313,6 +313,16 @@ return; } + // Only attach the Authorization header if the request was initiated by an + // allowed origin, or is a browser-initiated navigation (no initiator). + if (modified_request.request_initiator.has_value() && + !ShouldAddAuthHeader(modified_request.request_initiator->GetURL())) { + target_factory_->CreateLoaderAndStart( + std::move(loader), request_id, options, modified_request, + std::move(client), traffic_annotation); + return; + } + OMNIBOX_LOG("nav_trace") << "ContextualTasks navigation trace: CreateLoaderAndStart " "asking ContextualTasksUiService for AccessToken"; ui_service_->GetAccessToken( diff --git a/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor_browsertest.cc b/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor_browsertest.cc index fb6de45..f92eaad0 100644 --- a/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor_browsertest.cc +++ b/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor_browsertest.cc @@ -221,7 +221,8 @@ if (!auth_header_value.empty() || !ua_header_value.empty() || !cookie_header_value.empty() || request.relative_url.find("onegoogle") != std::string::npos || - request.relative_url.find("denylist") != std::string::npos) { + request.relative_url.find("denylist") != std::string::npos || + request.relative_url.find("untrusted") != std::string::npos) { content::GetUIThreadTaskRunner({})->PostTask( FROM_HERE, base::BindOnce( @@ -342,6 +343,7 @@ // Script to find the webview and perform a cross-origin fetch. // We navigate the webview to about:blank (no interception), then // fetch from kTestHost (interception enabled). + GURL target_url = https_server_.GetURL(kTestHost, "/blank.html"); GURL fetch_url = https_server_.GetURL(kTestHost, "/echoheader?Authorization"); std::string script = content::JsReplace( @@ -372,7 +374,7 @@ const webview = await waitFor('#threadFrame', app.shadowRoot); // Navigate webview first - const targetUrl = 'data:text/html,<html><body></body></html>'; + const targetUrl = $1; webview.src = targetUrl; // Wait for load @@ -394,10 +396,10 @@ }); // Execute fetch inside webview - webview.executeScript({code: `fetch($1, {headers: {'Authorization': 'Custom foo'}});`}); + webview.executeScript({code: `fetch($2, {headers: {'Authorization': 'Custom foo'}});`}); })(); )", - fetch_url.spec()); + target_url.spec(), fetch_url.spec()); EXPECT_TRUE(content::ExecJs(web_ui_contents, script)); @@ -500,6 +502,7 @@ // Script to find the webview and perform a cross-origin fetch. // We navigate the webview to about:blank (no interception), then // fetch from "sub.google.com" (interception enabled). + GURL target_url = https_server_.GetURL(kTestHost, "/blank.html"); GURL fetch_url = https_server_.GetURL(kTestSubHost, "/preflight-retry"); std::string script = content::JsReplace( @@ -530,7 +533,7 @@ const webview = await waitFor('#threadFrame', app.shadowRoot); // Navigate webview first - const targetUrl = 'data:text/html,<html><body></body></html>'; + const targetUrl = $1; webview.src = targetUrl; // Wait for load @@ -552,10 +555,10 @@ }); // Execute fetch inside webview - webview.executeScript({code: `fetch($1, {mode: 'cors'});`}); + webview.executeScript({code: `fetch($2, {mode: 'cors'});`}); })(); )", - fetch_url.spec()); + target_url.spec(), fetch_url.spec()); EXPECT_TRUE(content::ExecJs(web_ui_contents, script)); @@ -616,6 +619,85 @@ } IN_PROC_BROWSER_TEST_F(ContextualTasksUrlLoaderFactoryInterceptorBrowserTest, + UntrustedInitiatorOriginDoesNotHaveAuthToken) { + base::RunLoop run_loop; + header_capture_quit_closure_ = run_loop.QuitClosure(); + + ASSERT_TRUE(ui_test_utils::NavigateToURL( + browser(), GURL(chrome::kChromeUIContextualTasksURL))); + + content::WebContents* web_ui_contents = + TabListInterface::From(browser())->GetActiveTab()->GetContents(); + + // Destination is a trusted Google host, but initiator will be an untrusted + // data: origin. + GURL fetch_url = + https_server_.GetURL(kTestHost, "/untrusted/echoheader?Authorization"); + + std::string script = content::JsReplace( + R"( + (async () => { + const waitFor = (selector, scope = document) => { + return new Promise(resolve => { + if (scope.querySelector(selector)) { + return resolve(scope.querySelector(selector)); + } + const observer = new MutationObserver(() => { + if (scope.querySelector(selector)) { + observer.disconnect(); + resolve(scope.querySelector(selector)); + } + }); + observer.observe(scope, {childList: true, subtree: true}); + }); + }; + + const app = await waitFor('contextual-tasks-app'); + await new Promise(resolve => { + app.setOnLoadStartFinishedCallbackForTesting(resolve); + }); + if (!app.shadowRoot) { + await customElements.whenDefined('contextual-tasks-app'); + } + const webview = await waitFor('#threadFrame', app.shadowRoot); + + // Navigate webview to an untrusted origin (data: URL). + const targetUrl = 'data:text/html,<html><body></body></html>'; + webview.src = targetUrl; + + // Wait for load + await new Promise((resolve, reject) => { + const stop = () => { + webview.removeEventListener('loadstop', stop); + webview.removeEventListener('loadabort', abort); + resolve(); + }; + const abort = (e) => { + if (e.url === targetUrl) { + webview.removeEventListener('loadstop', stop); + webview.removeEventListener('loadabort', abort); + reject('Load aborted for ' + e.url + ': ' + e.reason); + } + }; + webview.addEventListener('loadstop', stop); + webview.addEventListener('loadabort', abort); + }); + + // Execute fetch inside webview to trusted Google destination. + webview.executeScript({code: `fetch($1, {mode: 'cors'});`}); + })(); + )", + fetch_url.spec()); + + EXPECT_TRUE(content::ExecJs(web_ui_contents, script)); + + run_loop.Run(); + + // Verify that the Authorization header was NOT injected. + EXPECT_TRUE(captured_auth_header_.empty()); +} + +IN_PROC_BROWSER_TEST_F(ContextualTasksUrlLoaderFactoryInterceptorBrowserTest, ListAccountsIntercepted) { base::RunLoop run_loop; header_capture_quit_closure_ = run_loop.QuitClosure();
Regression Test / PoC
diff --git a/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor_browsertest.cc b/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor_browsertest.cc
index fb6de45..f92eaad0 100644
--- a/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor_browsertest.cc
+++ b/chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor_browsertest.cc
@@ -221,7 +221,8 @@
if (!auth_header_value.empty() || !ua_header_value.empty() ||
!cookie_header_value.empty() ||
request.relative_url.find("onegoogle") != std::string::npos ||
- request.relative_url.find("denylist") != std::string::npos) {
+ request.relative_url.find("denylist") != std::string::npos ||
+ request.relative_url.find("untrusted") != std::string::npos) {
content::GetUIThreadTaskRunner({})->PostTask(
FROM_HERE,
base::BindOnce(
@@ -342,6 +343,7 @@
// Script to find the webview and perform a cross-origin fetch.
// We navigate the webview to about:blank (no interception), then
// fetch from kTestHost (interception enabled).
+ GURL target_url = https_server_.GetURL(kTestHost, "/blank.html");
GURL fetch_url = https_server_.GetURL(kTestHost, "/echoheader?Authorization");
std::string script = content::JsReplace(
@@ -372,7 +374,7 @@
const webview = await waitFor('#threadFrame', app.shadowRoot);
// Navigate webview first
- const targetUrl = 'data:text/html,<html><body></body></html>';
+ const targetUrl = $1;
webview.src = targetUrl;
// Wait for load
@@ -394,10 +396,10 @@
});
// Execute fetch inside webview
- webview.executeScript({code: `fetch($1, {headers: {'Authorization': 'Custom foo'}});`});
+ webview.executeScript({code: `fetch($2, {headers: {'Authorization': 'Custom foo'}});`});
})();
)",
- fetch_url.spec());
+ target_url.spec(), fetch_url.spec());
EXPECT_TRUE(content::ExecJs(web_ui_contents, script));
@@ -500,6 +502,7 @@
// Script to find the webview and perform a cross-origin fetch.
// We navigate the webview to about:blank (no interception), then
// fetch from "sub.google.com" (interception enabled).
+ GURL target_url = https_server_.GetURL(kTestHost, "/blank.html");
GURL fetch_url = https_server_.GetURL(kTestSubHost, "/preflight-retry");
std::string script = content::JsReplace(
@@ -530,7 +533,7 @@
const webview = await waitFor('#threadFrame', app.shadowRoot);
// Navigate webview first
- const targetUrl = 'data:text/html,<html><body></body></html>';
+ const targetUrl = $1;
webview.src = targetUrl;
// Wait for load
@@ -552,10 +555,10 @@
});
// Execute fetch inside webview
- webview.executeScript({code: `fetch($1, {mode: 'cors'});`});
+ webview.executeScript({code: `fetch($2, {mode: 'cors'});`});
})();
)",
- fetch_url.spec());
+ target_url.spec(), fetch_url.spec());
EXPECT_TRUE(content::ExecJs(web_ui_contents, script));
@@ -616,6 +619,85 @@
}
IN_PROC_BROWSER_TEST_F(ContextualTasksUrlLoaderFactoryInterceptorBrowserTest,
+ UntrustedInitiatorOriginDoesNotHaveAuthToken) {
+ base::RunLoop run_loop;
+ header_capture_quit_closure_ = run_loop.QuitClosure();
+
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), GURL(chrome::kChromeUIContextualTasksURL)));
+
+ content::WebContents* web_ui_contents =
+ TabListInterface::From(browser())->GetActiveTab()->GetContents();
+
+ // Destination is a trusted Google host, but initiator will be an untrusted
+ // data: origin.
+ GURL fetch_url =
+ https_server_.GetURL(kTestHost, "/untrusted/echoheader?Authorization");
+
+ std::string script = content::JsReplace(
+ R"(
+ (async () => {
+ const waitFor = (selector, scope = document) => {
+ return new Promise(resolve => {
+ if (scope.querySelector(selector)) {
+ return resolve(scope.querySelector(selector));
+ }
+ const observer = new MutationObserver(() => {
+ if (scope.querySelector(selector)) {
+ observer.disconnect();
+ resolve(scope.querySelector(selector));
+ }
+ });
+ observer.observe(scope, {childList: true, subtree: true});
+ });
+ };
+
+ const app = await waitFor('contextual-tasks-app');
+ await new Promise(resolve => {
+ app.setOnLoadStartFinishedCallbackForTesting(resolve);
+ });
+ if (!app.shadowRoot) {
+ await customElements.whenDefined('contextual-tasks-app');
+ }
+ const webview = await waitFor('#threadFrame', app.shadowRoot);
+
+ // Navigate webview to an untrusted origin (data: URL).
+ const targetUrl = 'data:text/html,<html><body></body></html>';
+ webview.src = targetUrl;
+
+ // Wait for load
+ await new Promise((resolve, reject) => {
+ const stop = () => {
+ webview.removeEventListener('loadstop', stop);
+ webview.removeEventListener('loadabort', abort);
+ resolve();
+ };
+ const abort = (e) => {
+ if (e.url === targetUrl) {
+ webview.removeEventListener('loadstop', stop);
+ webview.removeEventListener('loadabort', abort);
+ reject('Load aborted for ' + e.url + ': ' + e.reason);
+ }
+ };
+ webview.addEventListener('loadstop', stop);
+ webview.addEventListener('loadabort', abort);
+ });
+
+ // Execute fetch inside webview to trusted Google destination.
+ webview.executeScript({code: `fetch($1, {mode: 'cors'});`});
+ })();
+ )",
+ fetch_url.spec());
+
+ EXPECT_TRUE(content::ExecJs(web_ui_contents, script));
+
+ run_loop.Run();
+
+ // Verify that the Authorization header was NOT injected.
+ EXPECT_TRUE(captured_auth_header_.empty());
+}
+
+IN_PROC_BROWSER_TEST_F(ContextualTasksUrlLoaderFactoryInterceptorBrowserTest,
ListAccountsIntercepted) {
base::RunLoop run_loop;
header_capture_quit_closure_ = run_loop.QuitClosure();