CVE-2026-95340
Overview
Background
- Auto Picture-in-Picture
- a feature that lets an eligible document automatically enter a floating video overlay (
Auto-PiP) when the user switches away from the tab, gated by permission and eligibility checks. - Opaque origin
- a security origin with no meaningful scheme/host (serialized as
"null") assigned to documents committed under a CSPsandboxdirective lackingallow-same-origin. - `AutoPictureInPictureTabHelper`
- the
WebContents-scoped browser-side helper that decides eligibility and drives Auto-PiP entry for the primary main frame. - `PrimaryMainFrameHasOpaqueOrigin()`
- a helper that reports whether the primary main frame committed with an opaque security origin, distinguishing sandboxed documents from same-origin ones.
Root Cause Analysis
The eligibility and permission logic in AutoPictureInPictureTabHelper::IsEligibleForAutoPictureInPicture keyed its decisions on the URL returned by WebContents::GetLastCommittedURL() without checking whether the primary main frame actually committed with an opaque origin. A CSP-sandboxed document (served without allow-same-origin) commits with an opaque origin yet still reports the outer document’s last-committed URL, so the sandboxed frame was treated as if it carried the URL’s origin. This violated the invariant that a document should only exercise permissions belonging to its own security origin, letting a sandboxed document enter Auto-PiP and inherit permissions granted to the outer origin.
The fix adds an explicit PrimaryMainFrameHasOpaqueOrigin() check so that documents with an opaque origin are excluded from eligibility. Because opaque origins cannot legitimately hold origin-scoped permissions, denying them Auto-PiP restores the origin-authorization invariant.
PrimaryMainFrameHasOpaqueOrigin() so eligibility is denied whenever the frame’s real origin is opaque.Attack Path
- Serve a sandboxed document
An attacker hosts (or is embedded on) a page delivered with a CSP
sandboxdirective that omitsallow-same-origin, causing the primary main frame to commit with an opaque origin while still resolving to the outer URL. - Trigger Auto-PiP eligibility
The document plays media and registers for Auto-PiP;
IsEligibleForAutoPictureInPictureevaluates permission usingWebContents::GetLastCommittedURL()and, lacking an opaque-origin check, treats the sandboxed frame as the outer origin. - Inherit outer-origin permissions The sandboxed document enters Auto-PiP under permissions that belong to the outer origin, bypassing the intended origin authorization boundary.
- Abuse the inherited capability (inferred) With Auto-PiP granted under a stronger origin’s authorization, the sandboxed content could present overlay UI or exercise the feature in a context the sandbox was meant to prevent.
Impact Assessment
sandbox directive without allow-same-origin) while media and Auto-PiP registration proceed. The advisory classifies this as a LogicError / incorrect-authorization issue and states no exploited-in-the-wild status; any escalation beyond wrongful Auto-PiP entry is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionController.java |
modified | |
ifchrome/android/junit/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionControllerTest.java |
modified |
Files Changed
chrome/android/java/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionController.javachrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html.mock-http-headerschrome/browser/picture_in_picture/auto_picture_in_picture_tab_helper.ccchrome/android/junit/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionControllerTest.javachrome/browser/picture_in_picture/auto_picture_in_picture_tab_helper.hchrome/browser/picture_in_picture/test/android/java/src/org/chromium/chrome/browser/picture_in_picture/AutoPictureInPictureTabHelperTest.javachrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html
Audit Directions
- URL-vs-origin authorizationAudit permission and eligibility checks that key off
GetLastCommittedURL()or similar URL getters instead of the frame’s actual security origin, and add opaque-origin guards. - Sandboxed-frame inheritanceReview features reachable from documents committed under CSP
sandboxwithoutallow-same-origin, checking whether they can inherit origin-scoped permissions. - Prompt/controller lifecycleLook for controllers or observers registered before their UI is confirmed shown, where an unshown component is left registered as a valid owner.
Patch
From 7e945a3a36ac49e03b1da1764ec4721a7136f9a5 Mon Sep 17 00:00:00 2001 From: Phil Yan <[email protected]> Date: Mon, 31 Aug 2026 13:18:04 -0700 Subject: [PATCH] [AutoPiP] Block auto-pip for opaque origins Documents served with CSP sandbox directives without 'allow-same-origin' commit with an opaque origin (window.origin === "null"). Previously, Auto-PiP permission and eligibility checks relied on the URL from WebContents::GetLastCommittedURL() without checking whether the primary main frame has an opaque security origin. This allowed sandboxed documents to enter Auto-PiP and inherit permissions belonging to the outer origin. This CL ensures sandboxed documents cannot enter Auto-PiP: - AutoPictureInPictureTabHelper::IsEligibleForAutoPictureInPicture verifies that the primary main frame does not have an opaque origin via PrimaryMainFrameHasOpaqueOrigin(). - AutoPictureInPicturePermissionController in Java registers the permission controller conditionally only when prompt display succeeds, eliminating unshown controller rollback logic. TAG=agy CONV=18d20bd4-5827-471b-9457-125cd32dda4f Bug: 553268567 Test: chrome_junit_tests (AutoPictureInPicturePermissionControllerTest) Test: run_chrome_public_test_apk (AutoPictureInPictureTabHelperTest) Change-Id: If1dbac4c3eecb9e27ba98040622955a70460c18b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8311508 Commit-Queue: Phil Yan <[email protected]> Reviewed-by: Benjamin Keen <[email protected]> Cr-Commit-Position: refs/heads/main@{#1689195} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionController.java b/chrome/android/java/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionController.java index d8b891f64..477515c 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionController.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionController.java @@ -89,8 +89,8 @@ AutoPictureInPictureTabHelper helper = assertNonNull(AutoPictureInPictureTabHelper.fromWebContents(webContents)); - // Create the controller and register it with the helper. This prevents "fire and forget" - // by giving the controller a clear owner (the helper attached to the WebContents). + // Create the controller and register it with the helper only if it is successfully shown. + // This prevents "fire and forget" while ensuring an unshown controller is never registered. AutoPictureInPicturePermissionController controller = new AutoPictureInPicturePermissionController(webContents, closePipCallback); @@ -104,9 +104,10 @@ pipActivity.revertToRequestedBounds(); } }; - helper.setPermissionController(controller); - controller.show(activity); + if (controller.show(activity)) { + helper.setPermissionController(controller); + } } /** @@ -116,7 +117,7 @@ * @return True if a prompt is needed, false otherwise. */ public static boolean isPermissionPromptNeeded(WebContents webContents) { - if (!isAutoPictureInPictureInUse(webContents)) { + if (webContents == null || !isAutoPictureInPictureInUse(webContents)) { return false; } @@ -170,9 +171,9 @@ mClosePipCallback = closePipCallback; } - private void show(Activity activity) { + private boolean show(Activity activity) { if (mWebContents.isDestroyed() || activity.isFinishing() || activity.isDestroyed()) { - return; + return false; } mActivityWeakRef = new WeakReference<>(activity); @@ -180,7 +181,7 @@ ViewGroup rootView = activity.findViewById(android.R.id.content); if (rootView == null) { Log.w(TAG, "Could not find root view to attach Auto-PiP prompt."); - return; + return false; } // Add the privacy mask first, so it's behind the prompt. Use the activity context directly @@ -193,6 +194,7 @@ GlobalNightModeStateProviderHolder.getInstance().addObserver(this); updateViews(); + return true; } @Override diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionControllerTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionControllerTest.java index 057b96da..b8993e5 100644 --- a/chrome/android/junit/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionControllerTest.java +++ b/chrome/android/junit/src/org/chromium/chrome/browser/media/AutoPictureInPicturePermissionControllerTest.java @@ -100,7 +100,7 @@ @After public void tearDown() { - if (mActivityController != null) { + if (mActivityController != null && mActivity != null && !mActivity.isDestroyed()) { mActivityController.pause().stop().destroy(); } } @@ -172,6 +172,30 @@ } @Test + public void testShowPrompt_WhenActivityFinishing_DoesNotRegisterController() { + when(mNativeMock.getPermissionStatus(mWebContents)).thenReturn(ContentSetting.ASK); + mActivity.finish(); + + AutoPictureInPicturePermissionController.showPromptIfNeeded( + mActivity, mTab, NO_OP_CALLBACK); + + ViewGroup rootView = mActivity.findViewById(android.R.id.content); + Assert.assertEquals(0, rootView.getChildCount()); + Assert.assertNull(mTabHelper.getPermissionController()); + } + + @Test + public void testShowPrompt_WhenActivityDestroyed_DoesNotRegisterController() { + when(mNativeMock.getPermissionStatus(mWebContents)).thenReturn(ContentSetting.ASK); + mActivityController.pause().stop().destroy(); + + AutoPictureInPicturePermissionController.showPromptIfNeeded( + mActivity, mTab, NO_OP_CALLBACK); + + Assert.assertNull(mTabHelper.getPermissionController()); + } + + @Test public void testShowPrompt_AllowOnce_PreventsSubsequentPrompt() { // Show prompt and simulate "Allow Once" click. when(mNativeMock.getPermissionStatus(mWebContents)).thenReturn(ContentSetting.ASK); diff --git a/chrome/browser/picture_in_picture/auto_picture_in_picture_tab_helper.cc b/chrome/browser/picture_in_picture/auto_picture_in_picture_tab_helper.cc index 1462993..a79df1d 100644 --- a/chrome/browser/picture_in_picture/auto_picture_in_picture_tab_helper.cc +++ b/chrome/browser/picture_in_picture/auto_picture_in_picture_tab_helper.cc @@ -27,6 +27,7 @@ #include "components/permissions/permission_decision_auto_blocker.h" #include "content/public/browser/media_session.h" #include "content/public/browser/media_session_service.h" +#include "content/public/browser/render_frame_host.h" #include "media/base/media_switches.h" #include "mojo/public/cpp/bindings/remote.h" #include "third_party/blink/public/common/features.h" @@ -123,6 +124,14 @@ return has_ever_registered_for_auto_picture_in_picture_; } +bool AutoPictureInPictureTabHelper::PrimaryMainFrameHasOpaqueOrigin() const { + if (!web_contents()) { + return false; + } + const content::RenderFrameHost* rfh = web_contents()->GetPrimaryMainFrame(); + return rfh && rfh->GetLastCommittedOrigin().opaque(); +} + void AutoPictureInPictureTabHelper::PrimaryPageChanged(content::Page& page) { has_ever_registered_for_auto_picture_in_picture_ = false; // On navigation, forget any 'allow once' state. @@ -658,6 +667,10 @@ return false; } + if (PrimaryMainFrameHasOpaqueOrigin()) { + return false; + } + // Only https:// or file:// may autopip. const GURL url = web_contents()->GetLastCommittedURL(); if (!url.SchemeIs(url::kHttpsScheme) && !url.SchemeIsFile()) { diff --git a/chrome/browser/picture_in_picture/auto_picture_in_picture_tab_helper.h b/chrome/browser/picture_in_picture/auto_picture_in_picture_tab_helper.h index 5a7868dd7..496945d 100644 --- a/chrome/browser/picture_in_picture/auto_picture_in_picture_tab_helper.h +++ b/chrome/browser/picture_in_picture/auto_picture_in_picture_tab_helper.h @@ -245,6 +245,9 @@ bool IsEligibleForAutoPictureInPicture(bool should_record_blocking_metrics); + // Returns true if the primary main frame has an opaque security origin. + bool PrimaryMainFrameHasOpaqueOrigin() const; + // Returns true if the tab: // * Has audio focus // * Is playing unmuted playback diff --git a/chrome/browser/picture_in_picture/test/android/java/src/org/chromium/chrome/browser/picture_in_picture/AutoPictureInPictureTabHelperTest.java b/chrome/browser/picture_in_picture/test/android/java/src/org/chromium/chrome/browser/picture_in_picture/AutoPictureInPictureTabHelperTest.java index 911c51b..13b3df56 100644 --- a/chrome/browser/picture_in_picture/test/android/java/src/org/chromium/chrome/browser/picture_in_picture/AutoPictureInPictureTabHelperTest.java +++ b/chrome/browser/picture_in_picture/test/android/java/src/org/chromium/chrome/browser/picture_in_picture/AutoPictureInPictureTabHelperTest.java @@ -92,6 +92,8 @@ private static final String PIP_BUTTON_ID = "pip"; private static final String AUTO_PIP_VIDEO_PAGE = "/chrome/test/data/media/picture-in-picture/autopip-video.html"; + private static final String AUTO_PIP_VIDEO_SANDBOXED_PAGE = + "/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html"; private static final String AUTO_PIP_NOT_REGISTERED_PAGE = "/chrome/test/data/media/picture-in-picture/autopip-no-register.html"; private static final String VIDEO_CONFERENCING_PAGE = @@ -294,6 +296,29 @@ @Test @MediumTest + public void testDoesNotAutopipWithOpaqueOrigin() throws TimeoutException { + WebContents webContents = loadUrlAndInitializeForTest(AUTO_PIP_VIDEO_SANDBOXED_PAGE); + assertTrue( + "Page should have registered for auto-pip.", + AutoPictureInPictureTabHelperTestUtils.hasAutoPictureInPictureBeenRegistered( + webContents)); + + // Create a new tab in the background to switch to later. + Tab originalTab = mPage.getTab(); + Tab newTab = createNewTabInBackground(originalTab); + + fulfillVideoPlaybackConditions(webContents); + + // Switch away from the tab. + switchToTab(newTab); + + // Since the site has an opaque origin, it should not enter auto-PiP. + AutoPictureInPictureTabHelperTestUtils.waitForAutoPictureInPictureState( + webContents, false, "Should not enter auto-PiP with opaque origin."); + } + + @Test + @MediumTest public void testDoesNotAutopipWithoutPlayback() { WebContents webContents = loadUrlAndInitializeForTest(AUTO_PIP_VIDEO_PAGE); assertTrue( diff --git a/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html b/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html new file mode 100644 index 0000000..600ce9d5 --- /dev/null +++ b/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<head> +<meta charset="utf-8"> +<title>Autopip Video Sandboxed</title> +</head> +<body> + <video id="video" controls loop> + <source src="../bigbuck.webm"> + </video> + <button id="pip" onclick="openPip()">Enter Picture-in-Picture</button> +</body> +<script> +const video = document.getElementById('video'); + +function playVideo() { + navigator.mediaSession.playbackState = "playing"; + video.play(); +} + +function pauseVideo() { + video.pause(); + navigator.mediaSession.playbackState = "paused"; +} + +function muteVideo() { + video.muted = true; +} + +function openPip(details) { + video.requestPictureInPicture(); +} + +navigator.mediaSession.setActionHandler('enterpictureinpicture', openPip); +</script> diff --git a/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html.mock-http-headers b/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html.mock-http-headers new file mode 100644 index 0000000..82539c23 --- /dev/null +++ b/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html.mock-http-headers @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Security-Policy: sandbox allow-scripts \ No newline at end of file
Regression Test / PoC
diff --git a/chrome/browser/picture_in_picture/test/android/java/src/org/chromium/chrome/browser/picture_in_picture/AutoPictureInPictureTabHelperTest.java b/chrome/browser/picture_in_picture/test/android/java/src/org/chromium/chrome/browser/picture_in_picture/AutoPictureInPictureTabHelperTest.java
index 911c51b..13b3df56 100644
--- a/chrome/browser/picture_in_picture/test/android/java/src/org/chromium/chrome/browser/picture_in_picture/AutoPictureInPictureTabHelperTest.java
+++ b/chrome/browser/picture_in_picture/test/android/java/src/org/chromium/chrome/browser/picture_in_picture/AutoPictureInPictureTabHelperTest.java
@@ -92,6 +92,8 @@
private static final String PIP_BUTTON_ID = "pip";
private static final String AUTO_PIP_VIDEO_PAGE =
"/chrome/test/data/media/picture-in-picture/autopip-video.html";
+ private static final String AUTO_PIP_VIDEO_SANDBOXED_PAGE =
+ "/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html";
private static final String AUTO_PIP_NOT_REGISTERED_PAGE =
"/chrome/test/data/media/picture-in-picture/autopip-no-register.html";
private static final String VIDEO_CONFERENCING_PAGE =
@@ -294,6 +296,29 @@
@Test
@MediumTest
+ public void testDoesNotAutopipWithOpaqueOrigin() throws TimeoutException {
+ WebContents webContents = loadUrlAndInitializeForTest(AUTO_PIP_VIDEO_SANDBOXED_PAGE);
+ assertTrue(
+ "Page should have registered for auto-pip.",
+ AutoPictureInPictureTabHelperTestUtils.hasAutoPictureInPictureBeenRegistered(
+ webContents));
+
+ // Create a new tab in the background to switch to later.
+ Tab originalTab = mPage.getTab();
+ Tab newTab = createNewTabInBackground(originalTab);
+
+ fulfillVideoPlaybackConditions(webContents);
+
+ // Switch away from the tab.
+ switchToTab(newTab);
+
+ // Since the site has an opaque origin, it should not enter auto-PiP.
+ AutoPictureInPictureTabHelperTestUtils.waitForAutoPictureInPictureState(
+ webContents, false, "Should not enter auto-PiP with opaque origin.");
+ }
+
+ @Test
+ @MediumTest
public void testDoesNotAutopipWithoutPlayback() {
WebContents webContents = loadUrlAndInitializeForTest(AUTO_PIP_VIDEO_PAGE);
assertTrue(
diff --git a/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html b/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html
new file mode 100644
index 0000000..600ce9d5
--- /dev/null
+++ b/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<head>
+<meta charset="utf-8">
+<title>Autopip Video Sandboxed</title>
+</head>
+<body>
+ <video id="video" controls loop>
+ <source src="../bigbuck.webm">
+ </video>
+ <button id="pip" onclick="openPip()">Enter Picture-in-Picture</button>
+</body>
+<script>
+const video = document.getElementById('video');
+
+function playVideo() {
+ navigator.mediaSession.playbackState = "playing";
+ video.play();
+}
+
+function pauseVideo() {
+ video.pause();
+ navigator.mediaSession.playbackState = "paused";
+}
+
+function muteVideo() {
+ video.muted = true;
+}
+
+function openPip(details) {
+ video.requestPictureInPicture();
+}
+
+navigator.mediaSession.setActionHandler('enterpictureinpicture', openPip);
+</script>
diff --git a/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html.mock-http-headers b/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html.mock-http-headers
new file mode 100644
index 0000000..82539c23
--- /dev/null
+++ b/chrome/test/data/media/picture-in-picture/autopip-video-sandboxed.html.mock-http-headers
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Security-Policy: sandbox allow-scripts
\ No newline at end of file