Chrome · Picture-in-Picture
CVE-2026-17940
Logic Error in Picture-in-Picture
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/picture_in_picture/picture_in_picture_service_impl.cc |
modified |
Files Changed
content/browser/picture_in_picture/picture_in_picture_service_impl.cccontent/browser/picture_in_picture/picture_in_picture_service_impl.h
Patch
From ecdf6e4cdc1049924bc03afe66af0077bba3cc6c Mon Sep 17 00:00:00 2001
From: Oleh Desiatyrikov <desiatyrikov@google.com>
Date: Tue, 09 Jun 2026 10:01:29 -0700
Subject: [PATCH] Reland "[ImmersivePlayback] Simplify immersive Picture-in-Picture confirmation flow"
This is a reland of commit e472fe98af9472b48586022b7877e1c6579f0340
Original change's description:
> [ImmersivePlayback] Simplify immersive Picture-in-Picture confirmation flow
>
> Merges the separate RequestImmersivePlaybackConfirmation Mojo request
> directly into StartSession on the browser side.
>
> - Mojo changes: StartSession now receives a `bool request_immersive` flag.
> - State Machine: PictureInPictureServiceImpl implements a single-active-request confirmation queue. New/subsequent sessions immediately preempt and cancel any pending confirmation.
> - Fullscreen bugfix: Prevent exiting fullscreen when entering immersive Picture-in-Picture, as native fullscreen must remain active to display the dialog.
>
> Bug: 514069440
> Fixed: 514069440
> Change-Id: I694fb6b2d7a411b567ceb215f875e84f66b59dd5
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7868111
> Reviewed-by: Giovanni Ortuno Urquidi <ortuno@chromium.org>
> Reviewed-by: David Bokan <bokan@chromium.org>
> Reviewed-by: Frank Liberato <liberato@chromium.org>
> Commit-Queue: Oleh Desiatyrikov (xWF) <desiatyrikov@google.com>
> Cr-Commit-Position: refs/heads/main@{#1640570}
Bug: 514069440
Change-Id: I842e716a61c7a1371a63830f672f9edbb69b6e68
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7899097
Reviewed-by: Frank Liberato <liberato@chromium.org>
Commit-Queue: Oleh Desiatyrikov (xWF) <desiatyrikov@google.com>
Reviewed-by: Giovanni Ortuno Urquidi <ortuno@chromium.org>
Reviewed-by: David Bokan <bokan@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1644066}
---
diff --git a/content/browser/picture_in_picture/picture_in_picture_service_impl.cc b/content/browser/picture_in_picture/picture_in_picture_service_impl.cc
index 232f4b6..f8ad3ec 100644
--- a/content/browser/picture_in_picture/picture_in_picture_service_impl.cc
+++ b/content/browser/picture_in_picture/picture_in_picture_service_impl.cc
@@ -6,10 +6,13 @@
#include <utility>
+#include "base/functional/bind.h"
#include "content/browser/picture_in_picture/picture_in_picture_session.h"
#include "content/browser/picture_in_picture/video_picture_in_picture_window_controller_impl.h"
#include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/public/browser/web_contents.h"
#include "content/public/browser/web_contents_delegate.h"
+#include "mojo/public/cpp/bindings/callback_helpers.h"
namespace content {
@@ -38,15 +41,42 @@
bool show_play_pause_button,
mojo::PendingRemote<blink::mojom::PictureInPictureSessionObserver> observer,
const gfx::Rect& source_bounds,
- blink::mojom::ImmersiveOptionsPtr immersive_options,
+ bool request_immersive,
StartSessionCallback callback) {
+ // Invalidate any pending immersive confirmation flows. Note that this does
+ // not immediately destroy the `PendingSession` stored in the pending
+ // confirmation callback, as the callback object is owned by the controller
+ // and will be destroyed when the controller releases or runs the callback.
+ immersive_confirmation_weak_factory_.InvalidateWeakPtrs();
+
+ auto pending_session = std::make_unique<PendingSession>(
+ player_id, std::move(player_remote), surface_id, natural_size,
+ show_play_pause_button, std::move(observer), source_bounds,
+ mojo::WrapCallbackWithDefaultInvokeIfNotRun(
+ std::move(callback), mojo::NullRemote(), gfx::Size()));
+
+ if (!request_immersive) {
+ StartSessionInternal(std::move(pending_session),
+ /*immersive_options=*/nullptr);
+ } else {
+ StartSessionImmersive(std::move(pending_session));
+ }
+}
+
+void PictureInPictureServiceImpl::StartSessionInternal(
+ std::unique_ptr<PictureInPictureServiceImpl::PendingSession>
+ pending_session,
+ blink::mojom::ImmersiveOptionsPtr immersive_options) {
gfx::Size window_size;
mojo::PendingRemote<blink::mojom::PictureInPictureSession> session_remote;
auto result = GetController().StartSession(
- this, MediaPlayerId(render_frame_host().GetGlobalId(), player_id),
- std::move(player_remote), surface_id, natural_size,
- show_play_pause_button, std::move(observer), source_bounds,
+ this,
+ MediaPlayerId(render_frame_host().GetGlobalId(),
+ pending_session->player_id),
+ std::move(pending_session->player_remote), pending_session->surface_id,
+ pending_session->natural_size, pending_session->show_play_pause_button,
+ std::move(pending_session->observer), pending_session->source_bounds,
std::move(immersive_options), &session_remote, &window_size);
if (result == PictureInPictureResult::kSuccess) {
@@ -58,14 +88,58 @@
blink::scheduler::WebSchedulerTrackedFeature::kPictureInPicture);
}
- std::move(callback).Run(std::move(session_remote), window_size);
+ std::move(pending_session->callback)
+ .Run(std::move(session_remote), window_size);
}
-void PictureInPictureServiceImpl::RequestImmersivePlaybackConfirmation(
- RequestImmersivePlaybackConfirmationCallback callback) {
- GetController().RequestImmersivePlaybackConfirmation(std::move(callback));
+void PictureInPictureServiceImpl::StartSessionImmersive(
+ std::unique_ptr<PictureInPictureServiceImpl::PendingSession>
+ pending_session) {
+ // Immersive playback confirmation flow can only be requested in a
+ // browser-native fullscreen state.
+ auto* web_contents = WebContents::FromRenderFrameHost(&render_frame_host());
+ if (!web_contents || !web_contents->IsFullscreen()) {
+ return;
+ }
+
+ GetController().RequestImmersivePlaybackConfirmation(base::BindOnce(
+ &PictureInPictureServiceImpl::OnImmersivePlaybackConfirmation,
+ immersive_confirmation_weak_factory_.GetWeakPtr(),
+ std::move(pending_session)));
}
+void PictureInPictureServiceImpl::OnImmersivePlaybackConfirmation(
+ std::unique_ptr<PendingSession> pending_session,
+ blink::mojom::ImmersivePlaybackConfirmationResultPtr result) {
+ if (result->status !=
+ blink::mojom::ImmersivePlaybackConfirmationStatus::kConfirmed ||
+ !result->options) {
+ return;
+ }
+
+ StartSessionInternal(std::move(pending_session), std::move(result->options));
+}
+
+PictureInPictureServiceImpl::PendingSession::PendingSession(
+ uint32_t player_id,
+ mojo::PendingAssociatedRemote<media::mojom::MediaPlayer> player_remote,
+ const viz::SurfaceId& surface_id,
+ const gfx::Size& natural_size,
+ bool show_play_pause_button,
+ mojo::PendingRemote<blink::mojom::PictureInPictureSessionObserver> observer,
+ const gfx::Rect& source_bounds,
+ PictureInPictureServiceImpl::StartSessionCallback callback)
+ : player_id(player_id),
+ player_remote(std::move(player_remote)),
+ surface_id(surface_id),
+ natural_size(natural_size),
+ show_play_pause_button(show_play_pause_button),
+ observer(std::move(observer)),
+ source_bounds(source_bounds),
+ callback(std::move(callback)) {}
+
+PictureInPictureServiceImpl::PendingSession::~PendingSession() = default;
+
PictureInPictureServiceImpl::PictureInPictureServiceImpl(
RenderFrameHost& render_frame_host,
mojo::PendingReceiver<blink::mojom::PictureInPictureService> receiver)
@@ -74,7 +148,11 @@
PictureInPictureServiceImpl::~PictureInPictureServiceImpl() {
// If the service is destroyed because the frame was destroyed, the session
// may still be active and it has to be shutdown before its dtor runs.
- GetController().OnServiceDeleted(this);
+ if (auto* controller =
+ VideoPictureInPictureWindowControllerImpl::FromWebContents(
+ WebContents::FromRenderFrameHost(&render_frame_host()))) {
+ controller->OnServiceDeleted(this);
+ }
}
VideoPictureInPictureWindowControllerImpl&
diff --git a/content/browser/picture_in_picture/picture_in_picture_service_impl.h b/content/browser/picture_in_picture/picture_in_picture_service_impl.h
index e84aacc..29b15e4 100644
--- a/content/browser/picture_in_picture/picture_in_picture_service_impl.h
+++ b/content/browser/picture_in_picture/picture_in_picture_service_impl.h
@@ -5,6 +5,7 @@
#ifndef CONTENT_BROWSER_PICTURE_IN_PICTURE_PICTURE_IN_PICTURE_SERVICE_IMPL_H_
#define CONTENT_BROWSER_PICTURE_IN_PICTURE_PICTURE_IN_PICTURE_SERVICE_IMPL_H_
+#include "base/memory/weak_ptr.h"
#include "content/common/content_export.h"
#include "content/public/browser/document_service.h"
#include "media/mojo/mojom/media_player.mojom.h"
@@ -49,20 +50,54 @@
bool show_play_pause_button,
mojo::PendingRemote<blink::mojom::PictureInPictureSessionObserver>,
const gfx::Rect& source_bounds,
- blink::mojom::ImmersiveOptionsPtr immersive_options,
+ bool request_immersive,
StartSessionCallback) final;
- void RequestImmersivePlaybackConfirmation(
- RequestImmersivePlaybackConfirmationCallback) final;
Loading diff…
Regression Test / PoC
shipped with the fix
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 05a8a314..8304362 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
@@ -5,6 +5,7 @@
#include "content/browser/picture_in_picture/picture_in_picture_service_impl.h"
#include <memory>
+#include <tuple>
#include <utility>
#include "base/memory/raw_ptr.h"
@@ -25,7 +26,10 @@
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "mojo/public/cpp/bindings/receiver.h"
#include "mojo/public/cpp/bindings/remote.h"
+#include "mojo/public/cpp/test_support/fake_message_dispatch_context.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
#include "testing/gmock/include/gmock/gmock.h"
+#include "third_party/blink/public/mojom/frame/fullscreen.mojom.h"
#include "third_party/blink/public/mojom/picture_in_picture/picture_in_picture.mojom.h"
using testing::_;
@@ -67,6 +71,23 @@
(base::OnceCallback<
void(blink::mojom::ImmersivePlaybackConfirmationResultPtr)>),
(override));
+
+ void EnterFullscreenModeForTab(
+ RenderFrameHost* requesting_frame,
+ const blink::mojom::FullscreenOptions& options) override {
+ is_fullscreen_ = true;
+ }
+
+ void ExitFullscreenModeForTab(WebContents* web_contents) override {
+ is_fullscreen_ = false;
+ }
+
+ bool IsFullscreenForTabOrPending(const WebContents* web_contents) override {
+ return is_fullscreen_;
+ }
+
+ private:
+ bool is_fullscreen_ = false;
};
class TestOverlayWindow : public VideoOverlayWindow {
@@ -187,6 +208,22 @@
mojo::Remote<blink::mojom::PictureInPictureService> service_remote;
service_impl_ = PictureInPictureServiceImpl::CreateForTesting(
render_frame_host, service_remote.BindNewPipeAndPassReceiver());
+
+ surface_id_ = viz::SurfaceId(
+ viz::FrameSinkId(1, 1),
+ viz::LocalSurfaceId(
+ 11, base::UnguessableToken::CreateForTesting(0x111111, 0)));
+
+ source_bounds_ = gfx::Rect(1, 2, 3, 4);
+ window_size_ = gfx::Size(42, 42);
+ show_play_pause_button_ = true;
+ player_id_ = 30;
+
+ default_immersive_options_ = blink::mojom::ImmersiveOptions::New();
+ default_immersive_options_->stereo_mode =
+ blink::mojom::ImmersiveStereoMode::kMono;
+ default_immersive_options_->projection_type =
+ blink::mojom::ImmersiveProjectionType::kQuad;
}
void TearDown() override {
@@ -198,6 +235,20 @@
PictureInPictureDelegate& delegate() { return delegate_; }
+ const viz::SurfaceId& surface_id() const { return surface_id_; }
+
+ const gfx::Rect& source_bounds() const { return source_bounds_; }
+
+ const gfx::Size& window_size() const { return window_size_; }
+
+ bool show_play_pause_button() const { return show_play_pause_button_; }
+
+ int player_id() const { return player_id_; }
+
+ const blink::mojom::ImmersiveOptionsPtr& default_immersive_options() const {
+ return default_immersive_options_;
+ }
+
mojo::PendingAssociatedRemote<media::mojom::MediaPlayer>
BindMediaPlayerReceiverAndPassRemote() {
return media_player_receiver_.BindMediaPlayerReceiverAndPassRemote();
@@ -205,6 +256,38 @@
void ResetMediaPlayerReceiver() { media_player_receiver_.receiver().reset(); }
+ PictureInPictureServiceImpl::StartSessionCallback BindSession(
+ mojo::Remote<blink::mojom::PictureInPictureSession>& session_remote_out,
+ gfx::Size& window_size_out) {
+ return base::BindLambdaForTesting(
+ [&session_remote_out, &window_size_out](
+ mojo::PendingRemote<blink::mojom::PictureInPictureSession> remote,
+ const gfx::Size& b) {
+ if (remote.is_valid()) {
+ session_remote_out.Bind(std::move(remote));
+ }
+ window_size_out = b;
+ });
+ }
+
+ void EnterFullscreen() {
+ // Simulate fullscreen being entered.
+ std::ignore = main_test_rfh()->frame_tree_node()->UpdateUserActivationState(
+ blink::mojom::UserActivationUpdateType::kNotifyActivation,
+ blink::mojom::UserActivationNotificationType::kTest);
+ main_test_rfh()->EnterFullscreen(blink::mojom::FullscreenOptions::New(),
+ base::DoNothing());
+ ASSERT_TRUE(contents()->IsFullscreen());
+ }
+
+ VideoPictureInPictureWindowControllerImpl* GetController() {
+ auto* controller =
+ VideoPictureInPictureWindowControllerImpl::GetOrCreateForWebContents(
+ contents());
+ CHECK(controller);
+ return controller;
+ }
+
private:
PictureInPictureTestBrowserClient browser_client_;
PictureInPictureDelegate delegate_;
@@ -212,15 +295,16 @@
raw_ptr<PictureInPictureServiceImpl> service_impl_;
// Required to pass a valid PendingRemote to StartSession() in the tests.
PictureInPictureMediaPlayerReceiver media_player_receiver_;
+ viz::SurfaceId surface_id_;
+ blink::mojom::ImmersiveOptionsPtr default_immersive_options_;
+ gfx::Rect source_bounds_;
+ gfx::Size window_size_;
+ bool show_play_pause_button_;
+ int player_id_;
};
TEST_F(PictureInPictureServiceImplTest, EnterPictureInPicture) {
- const int kPlayerVideoOnlyId = 30;
- const VideoPictureInPictureWindowControllerImpl* controller =
- VideoPictureInPictureWindowControllerImpl::GetOrCreateForWebContents(
- contents());
-
- ASSERT_TRUE(controller);
+ auto* controller = GetController();
DummyPictureInPictureSessionObserver observer;
mojo::Receiver<blink::mojom::PictureInPictureSessionObserver>
@@ -232,36 +316,23 @@
// If Picture-in-Picture there shouldn't be an active session.
EXPECT_FALSE(controller->active_session_for_testing());
- viz::SurfaceId surface_id = viz::SurfaceId(
- viz::FrameSinkId(1, 1),
- viz::LocalSurfaceId(
- 11, base::UnguessableToken::CreateForTesting(0x111111, 0)));
-
EXPECT_CALL(delegate(), IsPictureInPictureEnabled())
.WillRepeatedly(testing::Return(true));
EXPECT_CALL(delegate(), EnterPictureInPicture(contents()))
.WillRepeatedly(testing::Return(PictureInPictureResult::kSuccess));
- mojo::Remote<blink::mojom::PictureInPictureSession> session_remote;
- gfx::Size window_size;
+ mojo::Remote<blink::mojom::PictureInPictureSession> session_remote_out;
+ gfx::Size window_size_out;
- const gfx::Rect source_bounds(1, 2, 3, 4);
- service().StartSession(
- kPlayerVideoOnlyId, BindMediaPlayerReceiverAndPassRemote(), surface_id,
- gfx::Size(42, 42), true /* show_play_pause_button */,
- std::move(observer_remote), source_bounds,
- nullptr /* immersive_options */,
- base::BindLambdaForTesting(
- [&](mojo::PendingRemote<blink::mojom::PictureInPictureSession> remote,
- const gfx::Size& b) {
- if (remote.is_valid())
- session_remote.Bind(std::move(remote));
- window_size = b;
- }));
+ service().StartSession(player_id(), BindMediaPlayerReceiverAndPassRemote(),
+ surface_id(), window_size(), show_play_pause_button(),
+ std::move(observer_remote), source_bounds(),
+ /*request_immersive=*/false,
+ BindSession(session_remote_out, window_size_out));
- EXPECT_TRUE(session_remote);
- EXPECT_EQ(gfx::Size(42, 42), window_size);
- EXPECT_EQ(source_bounds, controller->GetSourceBounds());
+ EXPECT_TRUE(session_remote_out);
+ EXPECT_EQ(window_size(), window_size_out);
+ EXPECT_EQ(source_bounds(), controller->GetSourceBounds());
// Picture-in-Picture media player id should not be reset when the media is
// destroyed (e.g. video stops playing). This allows the Picture-in-Picture
@@ -271,61 +342,31 @@
}
TEST_F(PictureInPictureServiceImplTest, EnterPictureInPicture_NotSupported) {
- const int kPlayerVideoOnlyId = 30;
- const VideoPictureInPictureWindowControllerImpl* controller =
- VideoPictureInPictureWindowControllerImpl::GetOrCreateForWebContents(
- contents());
-
- ASSERT_TRUE(controller);
- EXPECT_FALSE(controller->active_session_for_testing());
-
- mojo::PendingRemote<blink::mojom::PictureInPictureSessionObserver>
- observer_remote;
- viz::SurfaceId surface_id = viz::SurfaceId(
- viz::FrameSinkId(1, 1),
- viz::LocalSurfaceId(
- 11, base::UnguessableToken::CreateForTesting(0x111111, 0)));
-
EXPECT_CALL(delegate(), IsPictureInPictureEnabled())
.WillRepeatedly(testing::Return(true));
EXPECT_CALL(delegate(), EnterPictureInPicture(contents()))
.WillRepeatedly(testing::Return(PictureInPictureResult::kNotSupported));
- mojo::Remote<blink::mojom::PictureInPictureSession> session_remote;
- gfx::Size window_size;
- const gfx::Rect source_bounds(1, 2, 3, 4);
+ mojo::PendingRemote<blink::mojom::PictureInPictureSessionObserver>
+ observer_remote;
+ mojo::Remote<blink::mojom::PictureInPictureSession> session_remote_out;
+ gfx::Size window_size_out;
+ service().StartSession(player_id(), BindMediaPlayerReceiverAndPassRemote(),
+ surface_id(), window_size(), show_play_pause_button(),
+ std::move(observer_remote), source_bounds(),
+ /*request_immersive=*/false,
+ BindSession(session_remote_out, window_size_out));
- service().StartSession(
- kPlayerVideoOnlyId, BindMediaPlayerReceiverAndPassRemote(), surface_id,
- gfx::Size(42, 42), true /* show_play_pause_button */,
- std::move(observer_remote), source_bounds,
- nullptr /* immersive_options */,
- base::BindLambdaForTesting(
- [&](mojo::PendingRemote<blink::mojom::PictureInPictureSession> remote,
- const gfx::Size& b) {
- if (remote.is_valid())
- session_remote.Bind(std::move(remote));
- window_size = b;
- }));
+ EXPECT_FALSE(GetController()->active_session_for_testing());
- EXPECT_FALSE(controller->active_session_for_testing());
-
- // The |session_remote| won't be bound because the |remote| received in the
- // StartSessionCallback will be invalid due to PictureInPictureSession not
+ // The |session_remote_out| won't be bound because the |remote| received in
+ // the StartSessionCallback will be invalid due to PictureInPictureSession not
// ever being created (meaning the the receiver won't be bound either).
- EXPECT_FALSE(session_remote);
- EXPECT_EQ(gfx::Size(), window_size);
+ EXPECT_FALSE(session_remote_out);
+ EXPECT_EQ(gfx::Size(), window_size_out);
}
TEST_F(PictureInPictureServiceImplTest, EnterImmersivePlayback) {
- const int kPlayerVideoOnlyId = 30;
- const VideoPictureInPictureWindowControllerImpl* controller =
- VideoPictureInPictureWindowControllerImpl::GetOrCreateForWebContents(
- contents());
-
- ASSERT_TRUE(controller);
- EXPECT_FALSE(controller->active_session_for_testing());
-
DummyPictureInPictureSessionObserver observer;
mojo::Receiver<blink::mojom::PictureInPictureSessionObserver>
observer_receiver(&observer);
@@ -333,50 +374,157 @@
observer_remote;
observer_receiver.Bind(observer_remote.InitWithNewPipeAndPassReceiver());
- viz::SurfaceId surface_id = viz::SurfaceId(
- viz::FrameSinkId(1, 1),
- viz::LocalSurfaceId(
- 11, base::UnguessableToken::CreateForTesting(0x111111, 0)));
+ EnterFullscreen();
EXPECT_CALL(delegate(), IsImmersivePlaybackEnabled())
.WillRepeatedly(testing::Return(true));
EXPECT_CALL(delegate(), EnterPictureInPicture(contents()))
.WillRepeatedly(testing::Return(PictureInPictureResult::kSuccess));
- mojo::Remote<blink::mojom::PictureInPictureSession> session_remote;
- gfx::Size window_size;
+ // Expect the delegate to confirm immersive playback with default options.
+ EXPECT_CALL(delegate(), RequestImmersivePlaybackConfirmation(_))
+ .WillOnce([options = default_immersive_options().Clone()](
+ base::OnceCallback<void(
+ blink::mojom::ImmersivePlaybackConfirmationResultPtr)>
+ callback) mutable {
+ auto result = blink::mojom::ImmersivePlaybackConfirmationResult::New();
... (truncated)
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.
References
On This Page