CVE-2026-8015
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/media/session/media_session_controller.cc |
modified | |
TEST_Fcontent/browser/media/session/media_session_controller_unittest.cc |
modified |
Files Changed
content/browser/media/media_web_contents_observer_unittest.cccontent/browser/media/session/media_session_controller.cccontent/browser/media/session/media_session_controller.hcontent/browser/media/session/media_session_controller_unittest.cccontent/browser/media/session/media_session_impl.cccontent/browser/media/session/media_session_impl_service_routing_unittest.cc
Patch
From 75237fb8ac7628cb60939fce07b12eeebc460a9f Mon Sep 17 00:00:00 2001
From: Phil Yan <philyan@chromium.org>
Date: Wed, 01 Apr 2026 17:12:16 -0700
Subject: [PATCH] [Media] Prevent user activation bypass on system audio focus resume
When a system event (like audio focus regain after a notification)
triggered a media playback resume, the browser sent a RequestPlay
IPC to the renderer. HTMLMediaElement::RequestPlay() unconditionally
granted a Transient User Activation token to bypass background
playback restrictions in WebMediaPlayerImpl.
Because the system audio resume was indistinguishable from a user
interaction (like pressing a Bluetooth headset button), a malicious
background page could listen for audio focus events to harvest a
free User Activation token without user interaction.
This CL fixes the vulnerability by:
1. Adding a `triggered_by_user` flag to the `RequestPlay` Mojo IPC.
2. Only granting the Transient User Activation token in
`HTMLMediaElement::RequestPlay` if the request was truly
user-triggered.
3. Adding a new `UnlockBackgroundPlayback` method to the
`WebMediaPlayer` interface so that background media playback
can still resume smoothly without relying on a fake user
activation token.
TODO: although `HTMLMediaElement::RequestPause` already has a
`triggered_by_user`, it's hardcoded to true in
`MediaSessionController::OnSuspend`. We need to set it dynamically by
plumbing the value from `MediaSessionImpl::OnSuspendInternal`.
Bug: 497548558
Change-Id: I3a86a6aa327b90fa2ff051c4fd07d893f1e4a30a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7714520
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
Reviewed-by: Alex Gough <ajgo@chromium.org>
Reviewed-by: Dale Curtis <dalecurtis@chromium.org>
Commit-Queue: Phil Yan <philyan@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1608880}
---
diff --git a/content/browser/media/media_web_contents_observer_unittest.cc b/content/browser/media/media_web_contents_observer_unittest.cc
index a6a8ac2d..10b9d3c 100644
--- a/content/browser/media/media_web_contents_observer_unittest.cc
+++ b/content/browser/media/media_web_contents_observer_unittest.cc
@@ -25,7 +25,7 @@
}
// media::mojom::MediaPlayer implementation.
- void RequestPlay() override {}
+ void RequestPlay(bool triggered_by_user) override {}
void RequestPause(bool triggered_by_user) override {}
void RequestSeekForward(base::TimeDelta seek_time) override {}
void RequestSeekBackward(base::TimeDelta seek_time) override {}
diff --git a/content/browser/media/session/media_session_controller.cc b/content/browser/media/session/media_session_controller.cc
index 4f3b326..947b8ae 100644
--- a/content/browser/media/session/media_session_controller.cc
+++ b/content/browser/media/session/media_session_controller.cc
@@ -56,11 +56,11 @@
->RequestPause(/*triggered_by_user=*/true);
}
-void MediaSessionController::OnResume(int player_id) {
+void MediaSessionController::OnResume(int player_id, bool triggered_by_user) {
DCHECK_EQ(player_id_, player_id);
web_contents_->media_web_contents_observer()
->GetMediaPlayerRemote(id_)
- ->RequestPlay();
+ ->RequestPlay(triggered_by_user);
}
void MediaSessionController::OnSeekForward(int player_id,
@@ -153,7 +153,7 @@
if (is_paused_) {
web_contents_->media_web_contents_observer()
->GetMediaPlayerRemote(id_)
- ->RequestPlay();
+ ->RequestPlay(/*triggered_by_user=*/true);
}
web_contents_->media_web_contents_observer()
->GetMediaPlayerRemote(id_)
diff --git a/content/browser/media/session/media_session_controller.h b/content/browser/media/session/media_session_controller.h
index e4db1a3..d1067b4a 100644
--- a/content/browser/media/session/media_session_controller.h
+++ b/content/browser/media/session/media_session_controller.h
@@ -59,7 +59,7 @@
// MediaSessionPlayerObserver implementation.
void OnSuspend(int player_id) override;
- void OnResume(int player_id) override;
+ void OnResume(int player_id, bool triggered_by_user) override;
void OnSeekForward(int player_id, base::TimeDelta seek_time) override;
void OnSeekBackward(int player_id, base::TimeDelta seek_time) override;
void OnSeekTo(int player_id, base::TimeDelta seek_time) override;
diff --git a/content/browser/media/session/media_session_controller_unittest.cc b/content/browser/media/session/media_session_controller_unittest.cc
index 2db7e2e..4c19d87 100644
--- a/content/browser/media/session/media_session_controller_unittest.cc
+++ b/content/browser/media/session/media_session_controller_unittest.cc
@@ -112,8 +112,9 @@
}
// media::mojom::MediaPlayer implementation.
- void RequestPlay() override {
+ void RequestPlay(bool triggered_by_user) override {
received_play_ = true;
+ received_play_triggered_by_user_ = triggered_by_user;
run_loop_->Quit();
}
@@ -186,6 +187,9 @@
// Getters used from MediaSessionControllerTest.
bool received_play() const { return received_play_; }
+ bool received_play_triggered_by_user() const {
+ return received_play_triggered_by_user_;
+ }
PauseRequestType received_pause() const { return received_pause_type_; }
@@ -220,6 +224,7 @@
mojo::AssociatedReceiver<media::mojom::MediaPlayer> receiver_{this};
bool received_play_{false};
+ bool received_play_triggered_by_user_{false};
double received_volume_multiplier_{0};
PauseRequestType received_pause_type_{PauseRequestType::kNone};
base::TimeDelta received_seek_forward_time_;
@@ -318,8 +323,9 @@
media_player_->WaitUntilReceivedMessage();
}
- void Resume() {
- controller_->OnResume(controller_->get_player_id_for_testing());
+ void Resume(bool triggered_by_user = true) {
+ controller_->OnResume(controller_->get_player_id_for_testing(),
+ triggered_by_user);
media_player_->WaitUntilReceivedMessage();
}
@@ -354,7 +360,11 @@
}
// Helpers to check the results of using the basic controls.
- bool ReceivedMessagePlay() { return media_player_->received_play(); }
+ bool ReceivedMessagePlay(bool triggered_by_user) {
+ return media_player_->received_play() &&
+ media_player_->received_play_triggered_by_user() ==
+ triggered_by_user;
+ }
bool ReceivedMessagePause(bool triggered_by_user) {
TestMediaPlayer::PauseRequestType expected_pause_request =
@@ -419,7 +429,7 @@
// Likewise verify the resume behavior.
Resume();
- EXPECT_TRUE(ReceivedMessagePlay());
+ EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/true));
// ...as well as the seek behavior.
const base::TimeDelta kTestSeekForwardTime = base::Seconds(1);
@@ -496,7 +506,14 @@
// Likewise verify the resume behavior.
Resume();
- EXPECT_TRUE(ReceivedMessagePlay());
+ EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/true));
+
+ Suspend();
+ EXPECT_TRUE(ReceivedMessagePause(/*triggered_by_user=*/true));
+
+ // Verify the system resume behavior.
+ Resume(/*triggered_by_user=*/false);
+ EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/false));
}
TEST_F(MediaSessionControllerTest, PositionState) {
diff --git a/content/browser/media/session/media_session_impl.cc b/content/browser/media/session/media_session_impl.cc
index 24115ab..7d425bdf 100644
--- a/content/browser/media/session/media_session_impl.cc
+++ b/content/browser/media/session/media_session_impl.cc
@@ -977,8 +977,10 @@
if (suspend_type == SuspendType::kSystem && suspend_type_ != suspend_type)
return;
+ const bool triggered_by_user = (suspend_type == SuspendType::kUI);
+
for (const auto& it : normal_players_)
- it.first.observer->OnResume(it.first.player_id);
+ it.first.observer->OnResume(it.first.player_id, triggered_by_user);
RebuildAndNotifyMediaSessionInfoChanged();
}
diff --git a/content/browser/media/session/media_session_impl_service_routing_unittest.cc b/content/browser/media/session/media_session_impl_service_routing_unittest.cc
index 404d062..16298fb 100644
--- a/content/browser/media/session/media_session_impl_service_routing_unittest.cc
+++ b/content/browser/media/session/media_session_impl_service_routing_unittest.cc
@@ -64,7 +64,7 @@
~MockMediaSessionPlayerObserver() override = default;
Regression Test / PoC
diff --git a/content/browser/media/media_web_contents_observer_unittest.cc b/content/browser/media/media_web_contents_observer_unittest.cc
index a6a8ac2d..10b9d3c 100644
--- a/content/browser/media/media_web_contents_observer_unittest.cc
+++ b/content/browser/media/media_web_contents_observer_unittest.cc
@@ -25,7 +25,7 @@
}
// media::mojom::MediaPlayer implementation.
- void RequestPlay() override {}
+ void RequestPlay(bool triggered_by_user) override {}
void RequestPause(bool triggered_by_user) override {}
void RequestSeekForward(base::TimeDelta seek_time) override {}
void RequestSeekBackward(base::TimeDelta seek_time) override {}
diff --git a/content/browser/media/session/media_session_controller_unittest.cc b/content/browser/media/session/media_session_controller_unittest.cc
index 2db7e2e..4c19d87 100644
--- a/content/browser/media/session/media_session_controller_unittest.cc
+++ b/content/browser/media/session/media_session_controller_unittest.cc
@@ -112,8 +112,9 @@
}
// media::mojom::MediaPlayer implementation.
- void RequestPlay() override {
+ void RequestPlay(bool triggered_by_user) override {
received_play_ = true;
+ received_play_triggered_by_user_ = triggered_by_user;
run_loop_->Quit();
}
@@ -186,6 +187,9 @@
// Getters used from MediaSessionControllerTest.
bool received_play() const { return received_play_; }
+ bool received_play_triggered_by_user() const {
+ return received_play_triggered_by_user_;
+ }
PauseRequestType received_pause() const { return received_pause_type_; }
@@ -220,6 +224,7 @@
mojo::AssociatedReceiver<media::mojom::MediaPlayer> receiver_{this};
bool received_play_{false};
+ bool received_play_triggered_by_user_{false};
double received_volume_multiplier_{0};
PauseRequestType received_pause_type_{PauseRequestType::kNone};
base::TimeDelta received_seek_forward_time_;
@@ -318,8 +323,9 @@
media_player_->WaitUntilReceivedMessage();
}
- void Resume() {
- controller_->OnResume(controller_->get_player_id_for_testing());
+ void Resume(bool triggered_by_user = true) {
+ controller_->OnResume(controller_->get_player_id_for_testing(),
+ triggered_by_user);
media_player_->WaitUntilReceivedMessage();
}
@@ -354,7 +360,11 @@
}
// Helpers to check the results of using the basic controls.
- bool ReceivedMessagePlay() { return media_player_->received_play(); }
+ bool ReceivedMessagePlay(bool triggered_by_user) {
+ return media_player_->received_play() &&
+ media_player_->received_play_triggered_by_user() ==
+ triggered_by_user;
+ }
bool ReceivedMessagePause(bool triggered_by_user) {
TestMediaPlayer::PauseRequestType expected_pause_request =
@@ -419,7 +429,7 @@
// Likewise verify the resume behavior.
Resume();
- EXPECT_TRUE(ReceivedMessagePlay());
+ EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/true));
// ...as well as the seek behavior.
const base::TimeDelta kTestSeekForwardTime = base::Seconds(1);
@@ -496,7 +506,14 @@
// Likewise verify the resume behavior.
Resume();
- EXPECT_TRUE(ReceivedMessagePlay());
+ EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/true));
+
+ Suspend();
+ EXPECT_TRUE(ReceivedMessagePause(/*triggered_by_user=*/true));
+
+ // Verify the system resume behavior.
+ Resume(/*triggered_by_user=*/false);
+ EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/false));
}
TEST_F(MediaSessionControllerTest, PositionState) {
diff --git a/content/browser/media/session/media_session_impl_service_routing_unittest.cc b/content/browser/media/session/media_session_impl_service_routing_unittest.cc
index 404d062..16298fb 100644
--- a/content/browser/media/session/media_session_impl_service_routing_unittest.cc
+++ b/content/browser/media/session/media_session_impl_service_routing_unittest.cc
@@ -64,7 +64,7 @@
~MockMediaSessionPlayerObserver() override = default;
MOCK_METHOD1(OnSuspend, void(int player_id));
- MOCK_METHOD1(OnResume, void(int player_id));
+ MOCK_METHOD2(OnResume, void(int player_id, bool triggered_by_user));
MOCK_METHOD2(OnSeekForward, void(int player_id, base::TimeDelta seek_time));
MOCK_METHOD2(OnSeekBackward, void(int player_id, base::TimeDelta seek_time));
MOCK_METHOD2(OnSeekTo, void(int player_id, base::TimeDelta seek_time));
diff --git a/content/browser/media/session/media_session_service_impl_browsertest.cc b/content/browser/media/session/media_session_service_impl_browsertest.cc
index 9aa20b3..d4f839d 100644
--- a/content/browser/media/session/media_session_service_impl_browsertest.cc
+++ b/content/browser/media/session/media_session_service_impl_browsertest.cc
@@ -51,7 +51,7 @@
~MockMediaSessionPlayerObserver() override = default;
void OnSuspend(int player_id) override {}
- void OnResume(int player_id) override {}
+ void OnResume(int player_id, bool triggered_by_user) override {}
void OnSeekForward(int player_id, base::TimeDelta seek_time) override {}
void OnSeekBackward(int player_id, base::TimeDelta seek_time) override {}
void OnSeekTo(int player_id, base::TimeDelta seek_time) override {}
diff --git a/content/browser/picture_in_picture/picture_in_picture_service_impl_unittest.cc b/content/browser/picture_in_picture/picture_in_picture_service_impl_unittest.cc
index 1912b8d3..7818495 100644
--- a/content/browser/picture_in_picture/picture_in_picture_service_impl_unittest.cc
+++ b/content/browser/picture_in_picture/picture_in_picture_service_impl_unittest.cc
@@ -134,7 +134,7 @@
}
// media::mojom::MediaPlayer implementation.
- void RequestPlay() override {}
+ void RequestPlay(bool triggered_by_user) override {}
void RequestPause(bool triggered_by_user) override {}
void RequestSeekForward(base::TimeDelta seek_time) override {}
void RequestSeekBackward(base::TimeDelta seek_time) override {}
diff --git a/third_party/blink/renderer/core/exported/web_media_player_impl_unittest.cc b/third_party/blink/renderer/core/exported/web_media_player_impl_unittest.cc
index cd777aee..10306279 100644
--- a/third_party/blink/renderer/core/exported/web_media_player_impl_unittest.cc
+++ b/third_party/blink/renderer/core/exported/web_media_player_impl_unittest.cc
@@ -1038,6 +1038,20 @@
EXPECT_FALSE(IsSuspended());
}
+TEST_F(WebMediaPlayerImplTest, UnlockBackgroundPlayback) {
+ InitializeWebMediaPlayerImpl();
+
+ // Page hidden should lock background video playback.
+ delegate_.SetPageHiddenForTesting(true);
+ wmpi_->OnPageHidden();
+ EXPECT_TRUE(IsVideoLockedWhenPausedWhenHidden());
+
+ // UnlockBackgroundPlayback should unlock background video playback without
+ // user activation.
+ wmpi_->UnlockBackgroundPlayback();
+ EXPECT_FALSE(IsVideoLockedWhenPausedWhenHidden());
+}
+
// Verify LoadAndWaitForCurrentData() functions without issue.
TEST_F(WebMediaPlayerImplTest, LoadAndDestroy) {
InitializeWebMediaPlayerImpl();
diff --git a/third_party/blink/renderer/core/html/media/html_media_element_test.cc b/third_party/blink/renderer/core/html/media/html_media_element_test.cc
index 18f4c90..6c51f5b 100644
--- a/third_party/blink/renderer/core/html/media/html_media_element_test.cc
+++ b/third_party/blink/renderer/core/html/media/html_media_element_test.cc
@@ -54,6 +54,12 @@
namespace {
+// Helper to simulate a Mojo IPC message from the Browser process.
+void SimulateRequestPlay(HTMLMediaElement* element, bool triggered_by_user) {
+ static_cast<media::mojom::blink::MediaPlayer*>(element)->RequestPlay(
+ triggered_by_user);
+}
+
enum class TestURLScheme {
kHttp,
kHttps,
@@ -85,8 +91,11 @@
class MockWebMediaPlayer : public EmptyWebMediaPlayer {
public:
+ MOCK_METHOD0(Play, void());
+ MOCK_METHOD0(UnlockBackgroundPlayback, void());
MOCK_METHOD1(Pause, void(PauseReason));
MOCK_METHOD0(OnTimeUpdate, void());
+ MOCK_CONST_METHOD0(Paused, bool());
MOCK_CONST_METHOD0(Seekable, WebTimeRanges());
MOCK_METHOD0(OnFrozen, void());
MOCK_CONST_METHOD0(HasAudio, bool());
@@ -325,7 +334,8 @@
protected:
void SetUp() override {
// Sniff the media player pointer to facilitate mocking.
- auto mock_media_player = std::make_unique<MockWebMediaPlayer>();
+ auto mock_media_player =
+ std::make_unique<testing::NiceMock<MockWebMediaPlayer>>();
media_player_weak_ = mock_media_player->AsWeakPtr();
media_player_ = mock_media_player.get();
@@ -1807,6 +1817,43 @@
Media()->Play();
}
+TEST_P(HTMLMediaElementTest, RequestPlay_UserTriggered) {
+ Media()->SetSrc(SrcSchemeToURL(TestURLScheme::kHttp));
+ test::RunPendingTasks();
+ SetReadyState(HTMLMediaElement::kHaveEnoughData);
+ test::RunPendingTasks();
+
+ // It should just play directly when triggered by the user.
+ EXPECT_CALL(*MockMediaPlayer(),
+ SetWasPlayedWithUserActivationAndHighMediaEngagement(testing::_));
+ EXPECT_CALL(*MockMediaPlayer(), Paused()).WillRepeatedly(Return(true));
+ EXPECT_CALL(*MockMediaPlayer(), Play());
+ SimulateRequestPlay(Media(), /*triggered_by_user=*/true);
+
+ // The frame should be granted transient user activation.
+ EXPECT_TRUE(LocalFrame::HasTransientUserActivation(
+ Media()->GetDocument().GetFrame()));
+}
+
+TEST_P(HTMLMediaElementTest, RequestPlay_SystemTriggered) {
+ Media()->SetSrc(SrcSchemeToURL(TestURLScheme::kHttp));
+ test::RunPendingTasks();
+ SetReadyState(HTMLMediaElement::kHaveEnoughData);
+ test::RunPendingTasks();
+
+ // It should call UnlockBackgroundPlayback when triggered by the system.
+ EXPECT_CALL(*MockMediaPlayer(),
+ SetWasPlayedWithUserActivationAndHighMediaEngagement(testing::_));
+ EXPECT_CALL(*MockMediaPlayer(), UnlockBackgroundPlayback());
+ EXPECT_CALL(*MockMediaPlayer(), Paused()).WillRepeatedly(Return(true));
+ EXPECT_CALL(*MockMediaPlayer(), Play());
+ SimulateRequestPlay(Media(), /*triggered_by_user=*/false);
+
+ // The frame should NOT be granted transient user activation.
+ EXPECT_FALSE(LocalFrame::HasTransientUserActivation(
+ Media()->GetDocument().GetFrame()));
+}
+
TEST_P(HTMLMediaElementTest, PlayedWithUserActivation) {
Media()->SetSrc(SrcSchemeToURL(TestURLScheme::kHttp));
test::RunPendingTasks();
Original Bug Report
User Activation Bypass via HTMLMediaElement::RequestPlay and System Audio Focus Resume
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A logic flaw in HTMLMediaElement::RequestPlay allows media playback resumed by system events (like regaining audio focus on Android) to unconditionally grant user activation to the frame. This allows an attacker to bypass security requirements for gesture-gated APIs like requestFullscreen or window.open without user interaction. The root cause is a missing ’triggered_by_user’ parameter in the RequestPlay Mojo interface, unlike RequestPause.
Affected files:
third_party/blink/renderer/core/html/media/html_media_element.ccmedia/mojo/mojom/media_player.mojomcontent/browser/media/session/media_session_controller.cc
Estimated timestamp from git blame: 2021-02-19
Description
There is a potential user activation (gesture) bypass vulnerability in Chromium’s media playback handling. When media playback is requested from the browser process via Mojo, the HTMLMediaElement::RequestPlay function in the renderer unconditionally grants the frame a fresh 5-second transient user activation state.
While this is intended for genuine user interactions (like clicking ‘play’ on global media controls), the RequestPlay Mojo interface (media::mojom::MediaPlayer) takes no arguments and cannot differentiate between user-initiated and system-initiated events. This contrasts with the adjacent RequestPause(bool triggered_by_user) method.
This flaw becomes exploitable when a system event temporarily suspends and then automatically resumes media playback. On Android, a transient audio focus loss (e.g., a notification sound) triggers an automatic suspension (SuspendType::kSystem) and subsequent resume. When audio focus is regained, the browser instructs the renderer to resume playback by calling RequestPlay(), inadvertently granting the attacker’s page a fresh user activation token without any user interaction.
Potential Exploitation Steps
An attacker could follow these steps to trigger the vulnerability and bypass gesture gates:
- Attacker Setup: The attacker hosts a page with a
<video>or<audio>element. The attacker registers a JavaScriptplayevent listener on this element that attempts to call a gesture-gated API, such aselement.requestFullscreen(). - Initial Playback: The user navigates to the page and interacts with it once (or the page autoplays), starting media playback and giving Chrome OS-level audio focus.
- Transient Focus Loss: An external system event (e.g., an incoming text message notification) occurs. The Android OS signals
AUDIOFOCUS_LOSS_TRANSIENTto Chrome’sAudioFocusDelegate.java. - System Suspend: This triggers a JNI call to
AudioFocusDelegateAndroid::OnSuspend(content/browser/media/session/audio_focus_delegate_android.cc), which suspends the media session withSuspendType::kSystem. - Transient Focus Gain: The notification finishes, and Android signals
AUDIOFOCUS_GAIN. - System Resume:
AudioFocusDelegate.javacalls the nativeonResume(), triggeringAudioFocusDelegateAndroid::OnResume. This resumes the session withSuspendType::kSystem. - Mojo IPC: The resume propagates through
MediaSessionImpltoMediaSessionController::OnResume, which callsRequestPlay()on themedia::mojom::MediaPlayerMojo remote. - Unconditional Activation Grant: In the renderer process,
HTMLMediaElement::RequestPlay()(third_party/blink/renderer/core/html/media/html_media_element.cc:5104) is executed. It unconditionally callsLocalFrame::NotifyUserActivation(..., kInteraction), granting the frame a fresh 5-second transient activation token and syncing this state to the browser process. - Bypass Execution: The media resumes, firing the queued
playevent. The attacker’s event listener executes, and the call toelement.requestFullscreen()succeeds because the security gate (HasTransientUserActivation()) checks the newly granted, illegitimate activation state.
Note: These are potential steps based on extensive code tracing. Our automated tooling does not yet have the capability to run code to confirm a working Proof of Concept.
Proposed Fix
- Update the
media::mojom::MediaPlayerMojo interface (media/mojo/mojom/media_player.mojom) to include atriggered_by_userboolean parameter forRequestPlay, identical toRequestPause. - Plumb this parameter from the browser process, ensuring system-initiated resumes (e.g., from
AudioFocusDelegateAndroid) passfalse, while user-initiated actions (e.g., from global media controls) passtrue. - Update
HTMLMediaElement::RequestPlayin the renderer to only callLocalFrame::NotifyUserActivationiftriggered_by_useris true.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.