CVE-2026-79090
Overview
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.ccthat likewise let the AI overlay resume or suspend a tab’scontent::MediaSession. - `content::MediaSession::SuspendType`
- an enum telling
Resume/Suspendwho initiated the request. Per the commit message,kUIis reserved for physical human actions and unconditionally grants a user-activation gesture to the page, whilekSystemdoes 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.
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
- Malicious page in an agent tab
A page that the user’s AI agent (actor task or AI Overlay) is operating on registers
navigator.mediaSessionaction handlers and starts media so aMediaSessionexists. - 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. - Page gains user activation
Before the fix, the browser called
Resume/SuspendwithSuspendType::kUI, which granted the page a user-activation gesture without any physical user input. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/webui/ai_overlay_dialog/tools/tools.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/actor/tools/media_control_tool_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.cc |
modified | |
ifchrome/test/data/actor/media.html |
modified |
Files Changed
chrome/browser/actor/tools/media_control_tool_browsertest.ccchrome/browser/glic/host/glic_actor_media_control_tool_interactive_uitest.ccchrome/test/data/actor/media.htmlchrome/browser/ui/webui/ai_overlay_dialog/tools/tools.ccchrome/browser/actor/tools/media_control_tool.cc
Audit Directions
- Human-only enums on automated pathsGrep agent, extension and automation code (
chrome/browser/actor/,glic/, AI overlay tools, extension APIs) forSuspendType::kUIand other “user-initiated” markers (e.g.has_user_gesture,UserActivationnotifiers,NavigationHandleuser-gesture flags) that are set when no physical input occurred. - Other actor tools that synthesize activationReview 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 hooksAudit
content::MediaSessionand 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.
Patch
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>
Regression Test / PoC
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>