Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Contextual Tasks
DescriptionMissing authorization in Contextual Tasks
ComponentContextual Tasks
Sub-componentContext-input
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker501648493
Fix commit17f66f8a862a (chromium/src) +99/-7
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-11 (per vendor advisory)
Fix landed2026-09-02 (public git)
Disclosed2026-09-22
Patch gap20 days — fix public in source before the advisory

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::URLLoaderFactory implementation 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. subframe fetch) 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.

Key insight
The interceptor authorized token injection purely on the request’s destination and never on its initiator, making it a confused deputy; the fix additionally requires the initiator origin to pass ShouldAddAuthHeader before the OAuth token is attached.

Attack Path

  1. Load contextual tasks UI The user opens chrome://contextual-tasks, which embeds a <webview> guest whose network requests flow through ContextualTasksProxyingURLLoaderFactory.
  2. Introduce untrusted content An attacker gets an untrusted cross-origin document (e.g. a data: origin, as exercised by the test’s data:text/html navigation) loaded inside the guest.
  3. Issue cross-origin request to trusted endpoint The untrusted context calls fetch toward a trusted Google host, so the destination passes ShouldAddAuthHeader even though the initiator is untrusted.
  4. 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 Authorization Bearer header to the untrusted-initiated request.
  5. 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

An attacker able to run untrusted cross-origin content inside the contextual tasks guest could cause the user’s primary-account OAuth Bearer token to be attached to attacker-initiated requests to trusted Google endpoints, borrowing the user’s identity within the browser network context. The precondition is loading untrusted cross-origin content (such as a 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

FunctionChangeNotes
if
chrome/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.cc
  • chrome/browser/contextual_tasks/contextual_tasks_url_loader_factory_interceptor.cc

Audit Directions

  • Destination-only credential checks
    Audit every proxying URLLoaderFactory and header-injecting interceptor that decides to attach cookies, Authorization, or user-agent headers based solely on the target URL, and confirm they also validate request_initiator.
  • Guest/webview initiator trust
    Review features that embed <webview>/guest content and inject ambient credentials, checking that untrusted or cross-origin initiators (including data:, blob:, and sandboxed origins) cannot borrow primary-account tokens.
  • `nullopt` initiator handling
    Examine code that treats a missing request_initiator as trusted (browser-initiated), ensuring untrusted content cannot forge or omit the initiator to bypass the origin allowlist.
From 17f66f8a862a195b0334e3c1b7c06cc0b4dbd080 Mon Sep 17 00:00:00 2001
From: Jason Hu <hujasonx@google.com>
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 <hujasonx@google.com>
Reviewed-by: Duncan Mercer <mercerd@google.com>
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();
Loading diff…

Regression Test / PoC

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

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.