Low watch CVSS 9.8 chrome Authorization Error 🔧 Commit mapped

Overview

Low
Severity
9.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper privilege management in Actor
DescriptionImproper privilege management in Actor
ComponentActor
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.
Tracker517673944
Fix commit01dc0c8102c0 (chromium/src) +46/-28
CWECWE-269
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-29 (per vendor advisory)
Fix landed2026-06-30 (public git)
Disclosed2026-08-25
Patch gap56 days — fix public in source before the advisory

Background

Actor / `MediaControlTool`
the browser-side tool layer (chrome/browser/actor/tools/) that lets an AI agent (e.g. the Glic actor task) take actions in a tab, including play, pause and seek on the page’s media session.
AI Overlay WebUI tools
browser-side tool handlers in chrome/browser/ui/webui/ai_overlay_dialog/tools/tools.cc that likewise let the AI overlay resume or suspend a tab’s content::MediaSession.
`content::MediaSession::SuspendType`
an enum telling Resume/Suspend who initiated the request. Per the commit message, kUI is reserved for physical human actions and unconditionally grants a user-activation gesture to the page, while kSystem does not.
Transient user activation
Blink’s short-lived “the user just interacted” state that gates powerful web APIs such as popups and fullscreen. A page should only receive it from a genuine user gesture.

Root Cause Analysis

The actor MediaControlTool play/pause handlers and the AI Overlay resume/suspend tool handlers called media_session->Resume(...) / Suspend(...) with SuspendType::kUI. That value is meant for physical human input. The commit message states it unconditionally grants a user-activation gesture to the page. So an automated, agent-initiated action was presented to the web page as a real user gesture, breaking the rule that only genuine user input confers user activation. The removed test page shows how this was observable: navigator.mediaSession.setActionHandler('play'|'pause'|'seekto', ...) handlers registered by the site ran when the agent acted.

The fix switches all four call sites to SuspendType::kSystem, which the commit says suppresses the transient activation. A side effect is that site-registered media action handlers are skipped, which is why the tests now watch native play/pause/seeked events instead.

Key insight
Agent-driven media controls used the human-only SuspendType::kUI, so machine actions handed web pages a free user-activation token. Switching to SuspendType::kSystem stops agent actions from being treated as a user gesture.

Attack Path

  1. Malicious page in an agent tab A page that the user’s AI agent (actor task or AI Overlay) is operating on registers navigator.mediaSession action handlers and starts media so a MediaSession exists.
  2. Agent issues a media action The agent invokes PlayMedia/PauseMedia (actor) or the overlay’s resume/suspend tool. This might happen because the user asked, or (inferred) because content on the page steered the agent into doing it.
  3. Page gains user activation Before the fix, the browser called Resume/Suspend with SuspendType::kUI, which granted the page a user-activation gesture without any physical user input.
  4. Gesture-gated APIs unlocked The page could then call APIs gated on transient activation (for example popups or fullscreen) as if the user had clicked (inferred, since the patch does not name the specific APIs).

Impact Assessment

A web page gains a user-activation gesture it did not earn from the user, so it can bypass browser checks that require one. The effect is in the page’s renderer context, but the activation is granted by browser-side tool code. Reaching this requires the Actor/Glic agent or AI Overlay features to be enabled and actively controlling media in the attacker’s tab, so it is not reachable from ordinary browsing alone. The advisory describes it only as improper privilege management in Actor and gives no in-the-wild status. Any abuse beyond gesture-gated web APIs is (inferred).

Changed Functions

FunctionChangeNotes
if
chrome/browser/ui/webui/ai_overlay_dialog/tools/tools.cc
modified
IN_PROC_BROWSER_TEST_F
chrome/browser/actor/tools/media_control_tool_browsertest.cc
modified
IN_PROC_BROWSER_TEST_F
chrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc
modified
if
chrome/test/data/actor/media.html
modified

Files Changed

  • chrome/browser/actor/tools/media_control_tool_browsertest.cc
  • chrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc
  • chrome/test/data/actor/media.html
  • chrome/browser/ui/webui/ai_overlay_dialog/tools/tools.cc
  • chrome/browser/actor/tools/media_control_tool.cc

Audit Directions

  • Human-only enums on automated paths
    Grep agent, extension and automation code (chrome/browser/actor/, glic/, AI overlay tools, extension APIs) for SuspendType::kUI and other “user-initiated” markers (e.g. has_user_gesture, UserActivation notifiers, NavigationHandle user-gesture flags) that are set when no physical input occurred.
  • Other actor tools that synthesize activation
    Review the rest of the actor tool set (click, type, navigate, seek via SeekMedia) for places where browser-initiated actions are delivered to the renderer in a way that confers transient activation or bypasses gesture requirements unintentionally.
  • Activation granted via browser-to-renderer hooks
    Audit content::MediaSession and similar browser-side controllers for code paths that call into renderer action handlers with an implicit user gesture, and confirm the gesture is tied to real input events.
From 01dc0c8102c05be7b6fb72a311513dfb2d3959d7 Mon Sep 17 00:00:00 2001
From: Yiren Wang <[email protected]>
Date: Mon, 29 Jun 2026 17:02:30 -0700
Subject: [PATCH] Use SuspendType of kSystem for media session

MediaControlTool and AI Overlay WebUI tools currently control media
sessions using SuspendType::kUI, but SuspendType::kUI is reserved for
physical human actions and unconditionally grants user activation
gesture on the page.

This CL switches to use SuspendType::kSystem to suppress transient user
activation, and updates the corresponding tests. The downside of using
SuspendType::kSystem is that it will skip the media action handlers
registered by the sites.

Bug: 517673944
Change-Id: I6197826843c79962bb351a843b518cd47d1d308a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8013147
Commit-Queue: Yiren Wang <[email protected]>
Reviewed-by: David Bokan <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1654478}
---
diff --git a/chrome/browser/actor/tools/media_control_tool.cc b/chrome/browser/actor/tools/media_control_tool.cc
index 8129df27..27968a4 100644
--- a/chrome/browser/actor/tools/media_control_tool.cc
+++ b/chrome/browser/actor/tools/media_control_tool.cc
@@ -61,11 +61,11 @@
       absl::Overload(
           [media_session](const PlayMedia& arg) {
             // Resume media playback.
-            media_session->Resume(content::MediaSession::SuspendType::kUI);
+            media_session->Resume(content::MediaSession::SuspendType::kSystem);
           },
           [media_session](const PauseMedia& arg) {
             // Suspend media playback.
-            media_session->Suspend(content::MediaSession::SuspendType::kUI);
+            media_session->Suspend(content::MediaSession::SuspendType::kSystem);
           },
           [media_session](const SeekMedia& arg) {
             // Seek to a specific time in the media.
diff --git a/chrome/browser/ui/webui/ai_overlay_dialog/tools/tools.cc b/chrome/browser/ui/webui/ai_overlay_dialog/tools/tools.cc
index aa2a688..34d34d01 100644
--- a/chrome/browser/ui/webui/ai_overlay_dialog/tools/tools.cc
+++ b/chrome/browser/ui/webui/ai_overlay_dialog/tools/tools.cc
@@ -350,7 +350,7 @@
   content::MediaSession* media_session =
       content::MediaSession::GetIfExists(contents);
   if (media_session) {
-    media_session->Resume(content::MediaSession::SuspendType::kUI);
+    media_session->Resume(content::MediaSession::SuspendType::kSystem);
     std::move(callback).Run(std::monostate());
   } else {
     std::move(callback).Run(base::unexpected("No active media session"));
@@ -369,7 +369,7 @@
   content::MediaSession* media_session =
       content::MediaSession::GetIfExists(contents);
   if (media_session) {
-    media_session->Suspend(content::MediaSession::SuspendType::kUI);
+    media_session->Suspend(content::MediaSession::SuspendType::kSystem);
     std::move(callback).Run(std::monostate());
   } else {
     std::move(callback).Run(base::unexpected("No active media session"));
diff --git a/chrome/browser/actor/tools/media_control_tool_browsertest.cc b/chrome/browser/actor/tools/media_control_tool_browsertest.cc
index 426383f8..5a58a90 100644
--- a/chrome/browser/actor/tools/media_control_tool_browsertest.cc
+++ b/chrome/browser/actor/tools/media_control_tool_browsertest.cc
@@ -61,7 +61,7 @@
       MakeMediaControlRequest(*active_tab(), PauseMedia());
   actor_task().Act(ToRequestList(pause_request), pause_result.GetCallback());
   ExpectOkResult(pause_result);
-  EXPECT_EQ("pause", content::EvalJs(web_contents(), "event_log.join(',')"));
+  EXPECT_EQ(true, content::EvalJs(web_contents(), "waitForEvent('pause')"));
 
   // Play the media.
   ActResultFuture play_result;
@@ -69,8 +69,7 @@
       MakeMediaControlRequest(*active_tab(), PlayMedia());
   actor_task().Act(ToRequestList(play_request), play_result.GetCallback());
   ExpectOkResult(play_result);
-  EXPECT_EQ("pause,play",
-            content::EvalJs(web_contents(), "event_log.join(',')"));
+  EXPECT_EQ(true, content::EvalJs(web_contents(), "waitForEvent('play')"));
 }
 
 IN_PROC_BROWSER_TEST_F(ActorMediaControlToolBrowserTest, SeekMedia) {
@@ -78,8 +77,13 @@
   ASSERT_TRUE(content::NavigateToURL(web_contents(), url));
   ASSERT_TRUE(WaitForLoadStop(web_contents()));
 
-  // Start playback.
+  // Start playback to initialize media session.
   ASSERT_TRUE(content::ExecJs(web_contents(), "play()"));
+  EXPECT_EQ(true, content::EvalJs(web_contents(), "waitForEvent('play')"));
+
+  // Pause it so the currentTime doesn't drift during seek.
+  ASSERT_TRUE(content::ExecJs(web_contents(), "video.pause()"));
+  EXPECT_EQ(true, content::EvalJs(web_contents(), "waitForEvent('pause')"));
 
   // Seek the media.
   ActResultFuture result;
@@ -93,7 +97,10 @@
       ToRequestList(request, request_negative_time, request_unreachable_time),
       result.GetCallback());
   ExpectOkResult(result);
-  EXPECT_EQ("seek 1", content::EvalJs(web_contents(), "event_log.join(',')"));
+  EXPECT_EQ(true, content::EvalJs(web_contents(), "waitForSeek(1.0)"));
+  EXPECT_EQ(
+      1.0,
+      content::EvalJs(web_contents(), "video.currentTime").ExtractDouble());
 }
 
 }  // namespace
diff --git a/chrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc b/chrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc
index 7d448ae..86ef34f9 100644
--- a/chrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc
+++ b/chrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc
@@ -50,12 +50,11 @@
       InitializeWithOpenGlicWindow(),
       StartActorTaskInNewTab(url, kNewActorTabId),
       ExecuteJs(kNewActorTabId, "play"),
+      WaitForJsResult(kNewActorTabId, "() => waitForEvent('play')"),
       MediaControlAction(actor::PauseMedia()),
-      WaitForJsResult(kNewActorTabId, "() => { return event_log.join(','); }",
-                      "pause"),
+      WaitForJsResult(kNewActorTabId, "() => waitForEvent('pause')"),
       MediaControlAction(actor::PlayMedia()),
-      WaitForJsResult(kNewActorTabId, "() => { return event_log.join(','); }",
-                      "pause,play"));
+      WaitForJsResult(kNewActorTabId, "() => waitForEvent('play')"));
 }
 
 IN_PROC_BROWSER_TEST_F(GlicActorMediaControlToolUiTest, SeekMedia) {
@@ -65,9 +64,11 @@
       InitializeWithOpenGlicWindow(),
       StartActorTaskInNewTab(url, kNewActorTabId),
       ExecuteJs(kNewActorTabId, "play"),
+      WaitForJsResult(kNewActorTabId, "() => waitForEvent('play')"),
+      ExecuteJs(kNewActorTabId, "() => { video.pause(); }"),
+      WaitForJsResult(kNewActorTabId, "() => waitForEvent('pause')"),
       MediaControlAction(actor::SeekMedia{.seek_time_milliseconds = 1000}),
-      WaitForJsResult(kNewActorTabId, "() => { return event_log.join(','); }",
-                      "seek 1"));
+      WaitForJsResult(kNewActorTabId, "() => waitForSeek(1.0)"));
 }
 
 }  //  namespace
diff --git a/chrome/test/data/actor/media.html b/chrome/test/data/actor/media.html
index 118ab9d..ff120f87 100644
--- a/chrome/test/data/actor/media.html
+++ b/chrome/test/data/actor/media.html
@@ -9,26 +9,36 @@
   </video>
   <script>
     const video = document.getElementById('video');
-    let event_log = [];
-    setupActionHandlers();
 
     // Called by the test to start media playback.
     function play() {
       video.play();
     }
 
-    function setupActionHandlers() {
-      navigator.mediaSession.setActionHandler('play', _ => {
-        video.play();
-        event_log.push('play');
+    // Helper function used by tests to wait for playback state transitions.
+    // Returns `true` immediately if the video is already in the expected state.
+    // Otherwise, returns a Promise that resolves when the corresponding event is fired.
+    function waitForEvent(eventName) {
+      if (eventName === 'pause' && video.paused) {
+        return true;
+      }
+      if (eventName === 'play' && !video.paused) {
+        return true;
+      }
+      return new Promise(resolve => {
+        video.addEventListener(eventName, () => resolve(true), {once: true});
       });
-      navigator.mediaSession.setActionHandler('pause',  _ => {
-        video.pause();
-        event_log.push('pause');
-      });
-      navigator.mediaSession.setActionHandler('seekto', (details) => {
-        video.currentTime = details.seekTime;
-        event_log.push('seek ' + details.seekTime);
+    }
+
+    // Helper function used by tests to wait for seek transitions.
+    // Returns `true` immediately if the video has already seeked to `targetTime`.
+    // Otherwise, returns a Promise that resolves when the next native 'seeked' event fires.
+    function waitForSeek(targetTime) {
+      if (Math.abs(video.currentTime - targetTime) < 0.1) {
+        return true;
+      }
+      return new Promise(resolve => {
+        video.addEventListener('seeked', () => resolve(true), {once: true});
       });
     }
   </script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/actor/tools/media_control_tool_browsertest.cc b/chrome/browser/actor/tools/media_control_tool_browsertest.cc
index 426383f8..5a58a90 100644
--- a/chrome/browser/actor/tools/media_control_tool_browsertest.cc
+++ b/chrome/browser/actor/tools/media_control_tool_browsertest.cc
@@ -61,7 +61,7 @@
       MakeMediaControlRequest(*active_tab(), PauseMedia());
   actor_task().Act(ToRequestList(pause_request), pause_result.GetCallback());
   ExpectOkResult(pause_result);
-  EXPECT_EQ("pause", content::EvalJs(web_contents(), "event_log.join(',')"));
+  EXPECT_EQ(true, content::EvalJs(web_contents(), "waitForEvent('pause')"));
 
   // Play the media.
   ActResultFuture play_result;
@@ -69,8 +69,7 @@
       MakeMediaControlRequest(*active_tab(), PlayMedia());
   actor_task().Act(ToRequestList(play_request), play_result.GetCallback());
   ExpectOkResult(play_result);
-  EXPECT_EQ("pause,play",
-            content::EvalJs(web_contents(), "event_log.join(',')"));
+  EXPECT_EQ(true, content::EvalJs(web_contents(), "waitForEvent('play')"));
 }
 
 IN_PROC_BROWSER_TEST_F(ActorMediaControlToolBrowserTest, SeekMedia) {
@@ -78,8 +77,13 @@
   ASSERT_TRUE(content::NavigateToURL(web_contents(), url));
   ASSERT_TRUE(WaitForLoadStop(web_contents()));
 
-  // Start playback.
+  // Start playback to initialize media session.
   ASSERT_TRUE(content::ExecJs(web_contents(), "play()"));
+  EXPECT_EQ(true, content::EvalJs(web_contents(), "waitForEvent('play')"));
+
+  // Pause it so the currentTime doesn't drift during seek.
+  ASSERT_TRUE(content::ExecJs(web_contents(), "video.pause()"));
+  EXPECT_EQ(true, content::EvalJs(web_contents(), "waitForEvent('pause')"));
 
   // Seek the media.
   ActResultFuture result;
@@ -93,7 +97,10 @@
       ToRequestList(request, request_negative_time, request_unreachable_time),
       result.GetCallback());
   ExpectOkResult(result);
-  EXPECT_EQ("seek 1", content::EvalJs(web_contents(), "event_log.join(',')"));
+  EXPECT_EQ(true, content::EvalJs(web_contents(), "waitForSeek(1.0)"));
+  EXPECT_EQ(
+      1.0,
+      content::EvalJs(web_contents(), "video.currentTime").ExtractDouble());
 }
 
 }  // namespace
diff --git a/chrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc b/chrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc
index 7d448ae..86ef34f9 100644
--- a/chrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc
+++ b/chrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc
@@ -50,12 +50,11 @@
       InitializeWithOpenGlicWindow(),
       StartActorTaskInNewTab(url, kNewActorTabId),
       ExecuteJs(kNewActorTabId, "play"),
+      WaitForJsResult(kNewActorTabId, "() => waitForEvent('play')"),
       MediaControlAction(actor::PauseMedia()),
-      WaitForJsResult(kNewActorTabId, "() => { return event_log.join(','); }",
-                      "pause"),
+      WaitForJsResult(kNewActorTabId, "() => waitForEvent('pause')"),
       MediaControlAction(actor::PlayMedia()),
-      WaitForJsResult(kNewActorTabId, "() => { return event_log.join(','); }",
-                      "pause,play"));
+      WaitForJsResult(kNewActorTabId, "() => waitForEvent('play')"));
 }
 
 IN_PROC_BROWSER_TEST_F(GlicActorMediaControlToolUiTest, SeekMedia) {
@@ -65,9 +64,11 @@
       InitializeWithOpenGlicWindow(),
       StartActorTaskInNewTab(url, kNewActorTabId),
       ExecuteJs(kNewActorTabId, "play"),
+      WaitForJsResult(kNewActorTabId, "() => waitForEvent('play')"),
+      ExecuteJs(kNewActorTabId, "() => { video.pause(); }"),
+      WaitForJsResult(kNewActorTabId, "() => waitForEvent('pause')"),
       MediaControlAction(actor::SeekMedia{.seek_time_milliseconds = 1000}),
-      WaitForJsResult(kNewActorTabId, "() => { return event_log.join(','); }",
-                      "seek 1"));
+      WaitForJsResult(kNewActorTabId, "() => waitForSeek(1.0)"));
 }
 
 }  //  namespace
diff --git a/chrome/test/data/actor/media.html b/chrome/test/data/actor/media.html
index 118ab9d..ff120f87 100644
--- a/chrome/test/data/actor/media.html
+++ b/chrome/test/data/actor/media.html
@@ -9,26 +9,36 @@
   </video>
   <script>
     const video = document.getElementById('video');
-    let event_log = [];
-    setupActionHandlers();
 
     // Called by the test to start media playback.
     function play() {
       video.play();
     }
 
-    function setupActionHandlers() {
-      navigator.mediaSession.setActionHandler('play', _ => {
-        video.play();
-        event_log.push('play');
+    // Helper function used by tests to wait for playback state transitions.
+    // Returns `true` immediately if the video is already in the expected state.
+    // Otherwise, returns a Promise that resolves when the corresponding event is fired.
+    function waitForEvent(eventName) {
+      if (eventName === 'pause' && video.paused) {
+        return true;
+      }
+      if (eventName === 'play' && !video.paused) {
+        return true;
+      }
+      return new Promise(resolve => {
+        video.addEventListener(eventName, () => resolve(true), {once: true});
       });
-      navigator.mediaSession.setActionHandler('pause',  _ => {
-        video.pause();
-        event_log.push('pause');
-      });
-      navigator.mediaSession.setActionHandler('seekto', (details) => {
-        video.currentTime = details.seekTime;
-        event_log.push('seek ' + details.seekTime);
+    }
+
+    // Helper function used by tests to wait for seek transitions.
+    // Returns `true` immediately if the video has already seeked to `targetTime`.
+    // Otherwise, returns a Promise that resolves when the next native 'seeked' event fires.
+    function waitForSeek(targetTime) {
+      if (Math.abs(video.currentTime - targetTime) < 0.1) {
+        return true;
+      }
+      return new Promise(resolve => {
+        video.addEventListener('seeked', () => resolve(true), {once: true});
       });
     }
   </script>
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.