CVE-2026-106337
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImpl.java |
modified | |
ifchrome/android/java/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivity.java |
modified |
Files Changed
chrome/android/javatests/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivityTest.javachrome/android/junit/src/org/chromium/chrome/browser/media/document_picture_in_picture_header/DocumentPictureInPictureHeaderMediatorUnitTest.javachrome/android/junit/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivityUnitTest.javachrome/android/junit/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImplUnitTest.javachrome/browser/android/tab_web_contents_delegate_android.ccchrome/android/java/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivity.javachrome/android/java/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImpl.javachrome/android/java/src/org/chromium/chrome/browser/media/document_picture_in_picture_header/DocumentPictureInPictureHeaderMediator.javachrome/browser/picture_in_picture/picture_in_picture_window_manager.cc
Patch
From 544b1a332661a1119eedb26de496683033ab18f5 Mon Sep 17 00:00:00 2001
From: Phil Yan <philyan@chromium.org>
Date: Wed, 09 Sep 2026 12:46:32 -0700
Subject: [PATCH] [PiP] Fix URL and origin spoof in Document PiP on Android
Document Picture-in-Picture windows on Android could be opened from
unsupported or opaque opener contexts (such as about:blank or data:
URLs). Furthermore, DocumentPictureInPictureHeaderMediator fell back
to url.getSpec() for empty-host URLs while applying head-elision
(TruncateAt.START), allowing an attacker to craft a fragment like
about:blank#<padding><victim_url> that appeared as the victim URL in
the floating header. Additionally, verifyOpenerOrigin allowed opaque
origins because Origin.create() serialized to "null" and matched the
intent's "null" string.
This CL resolves the vulnerability via defense-in-depth:
1. Aligns Android with Desktop by checking
IsSupportedForDocumentPictureInPicture in
TabWebContentsDelegateAndroid::AddNewContents, blocking unsupported
opener schemes upfront.
2. In PopupCreatorImpl and DocumentPictureInPictureActivity, verifies
the opener frame's security origin (via getLastCommittedOrigin) and
explicitly rejects opaque origins.
3. In DocumentPictureInPictureHeaderMediator, displays the committed
URL rather than visible URL, returns an empty string for empty-host
URLs instead of the full spec, and restricts head-elision
(TruncateAt.START) strictly to HTTP/HTTPS schemes.
TAG=agy
CONV=8680a3cf-49ae-4541-b664-daab6219fe6a
Bug: 553145497
Change-Id: I462ee5770b838686f04f5b023ed22f6ecb3a39aa
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8351022
Reviewed-by: Tommy Steimel <steimel@chromium.org>
Commit-Queue: Phil Yan <philyan@chromium.org>
Reviewed-by: Calder Kitagawa <ckitagawa@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1694815}
---
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImpl.java b/chrome/android/java/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImpl.java
index f6be3dd..7926ef1a 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImpl.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImpl.java
@@ -52,13 +52,13 @@
import org.chromium.chrome.browser.util.AndroidTaskUtils;
import org.chromium.chrome.browser.util.PictureInPictureWindowOptions;
import org.chromium.chrome.browser.util.WindowFeatures;
+import org.chromium.content_public.browser.RenderFrameHost;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.display.DisplayAndroid;
import org.chromium.ui.display.DisplayUtil;
import org.chromium.ui.insets.InsetObserver;
import org.chromium.ui.insets.WindowInsetsUtils;
-import org.chromium.url.GURL;
import org.chromium.url.Origin;
/** Handles launching new popup windows as CCTs and Document Picture-in-Picture windows. */
@@ -530,11 +530,9 @@
// if the opener navigates before the Activity completes its launch.
WebContents opener = webContents.getDocumentPictureInPictureOpener();
if (opener != null) {
- GURL openerUrl = opener.getLastCommittedUrl();
- Origin openerOrigin = Origin.create(openerUrl != null ? openerUrl : GURL.emptyGURL());
intent.putExtra(
DocumentPictureInPictureActivity.INITIAL_OPENER_ORIGIN_KEY,
- openerOrigin.toString());
+ getOpenerOriginString(opener));
}
intent.setAction(Intent.ACTION_VIEW);
@@ -542,6 +540,17 @@
return intent;
}
+ private static String getOpenerOriginString(WebContents opener) {
+ RenderFrameHost frame = opener.getMainFrame();
+ Origin origin =
+ frame != null
+ ? frame.getLastCommittedOrigin()
+ : (opener.getLastCommittedUrl() != null
+ ? Origin.create(opener.getLastCommittedUrl())
+ : null);
+ return (origin != null && !origin.isOpaque()) ? origin.toString() : "";
+ }
+
private static @Nullable Bundle resolveStartActivityOptions(
@Nullable Bundle startActivityOptions,
@Nullable WindowFeatures windowFeatures,
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivity.java b/chrome/android/java/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivity.java
index 98d7882..b72a8d0 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivity.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivity.java
@@ -70,6 +70,7 @@
import org.chromium.components.thinwebview.ThinWebViewAttachParams;
import org.chromium.components.thinwebview.ThinWebViewConstraints;
import org.chromium.components.thinwebview.ThinWebViewFactory;
+import org.chromium.content_public.browser.RenderFrameHost;
import org.chromium.content_public.browser.Visibility;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.common.ResourceRequestBody;
@@ -875,17 +876,32 @@
* @param parentWebContents The opener's WebContents.
* @return True if the origins match, or if verification is skipped; false on mismatch.
*/
- private boolean verifyOpenerOrigin(Intent intent, WebContents parentWebContents) {
+ @VisibleForTesting
+ boolean verifyOpenerOrigin(Intent intent, WebContents parentWebContents) {
if (mIsFromActivityRecreation) {
return true; // Already verified on initial startup.
}
final String initialOpenerOriginStr = intent.getStringExtra(INITIAL_OPENER_ORIGIN_KEY);
- if (initialOpenerOriginStr == null) {
- Log.e(TAG, "No initial opener origin in intent! Finishing.");
+ if (initialOpenerOriginStr == null || initialOpenerOriginStr.isEmpty()) {
+ Log.e(TAG, "Missing or empty initial opener origin, finishing.");
return false;
}
- final GURL currentOpenerUrl = parentWebContents.getLastCommittedUrl();
- final String currentOpenerOriginStr = Origin.create(currentOpenerUrl).toString();
+ if (parentWebContents == null) {
+ Log.e(TAG, "Parent WebContents is null, finishing.");
+ return false;
+ }
+ final RenderFrameHost openerFrame = parentWebContents.getMainFrame();
+ final Origin currentOpenerOrigin =
+ openerFrame != null
+ ? openerFrame.getLastCommittedOrigin()
+ : (parentWebContents.getLastCommittedUrl() != null
+ ? Origin.create(parentWebContents.getLastCommittedUrl())
+ : null);
+ if (currentOpenerOrigin == null || currentOpenerOrigin.isOpaque()) {
+ Log.e(TAG, "Current opener origin is missing or opaque, finishing.");
+ return false;
+ }
+ final String currentOpenerOriginStr = currentOpenerOrigin.toString();
if (!initialOpenerOriginStr.equals(currentOpenerOriginStr)) {
Log.e(
TAG,
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/media/document_picture_in_picture_header/DocumentPictureInPictureHeaderMediator.java b/chrome/android/java/src/org/chromium/chrome/browser/media/document_picture_in_picture_header/DocumentPictureInPictureHeaderMediator.java
index a679759..b16edfd0 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/media/document_picture_in_picture_header/DocumentPictureInPictureHeaderMediator.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/media/document_picture_in_picture_header/DocumentPictureInPictureHeaderMediator.java
@@ -15,7 +15,6 @@
import androidx.core.graphics.Insets;
import org.chromium.base.DeviceInfo;
-import org.chromium.base.Log;
import org.chromium.build.annotations.MonotonicNonNull;
import org.chromium.build.annotations.NullMarked;
import org.chromium.build.annotations.Nullable;
@@ -25,6 +24,7 @@
import org.chromium.components.browser_ui.desktop_windowing.AppHeaderState;
import org.chromium.components.browser_ui.desktop_windowing.DesktopWindowStateManager;
import org.chromium.components.embedder_support.util.UrlConstants;
+import org.chromium.components.embedder_support.util.UrlUtilities;
import org.chromium.components.omnibox.SecurityStatusIcon;
import org.chromium.components.security_state.ConnectionMaliciousContentStatus;
import org.chromium.components.security_state.ConnectionSecurityLevel;
@@ -52,7 +52,6 @@
implements DesktopWindowStateManager.AppHeaderObserver,
ThemeColorProvider.ThemeColorObserver,
ThemeColorProvider.TintObserver {
- private static final String TAG = "DocumentPiPHdrMdtr";
private final PropertyModel mModel;
private @MonotonicNonNull AppHeaderState mCurrentHeaderState;
private final DesktopWindowStateManager mDesktopWindowStateManager;
@@ -121,15 +120,17 @@
onAppHeaderStateChanged(mDesktopWindowStateManager.getAppHeaderState());
updateSecurityIcon();
- GURL visibleUrl = mOpenerWebContents.getVisibleUrl();
- mModel.set(DocumentPictureInPictureHeaderProperties.URL_STRING, getUrlString(visibleUrl));
- // To prevent spoofing, local URLs are tail-elided (keeping the scheme prefix
- // visible) and standard web URLs are head-elided, matching desktop elision behavior.
+ GURL openerUrl = mOpenerWebContents.getLastCommittedUrl();
+ mModel.set(DocumentPictureInPictureHeaderProperties.URL_STRING, getUrlString(openerUrl));
+ // To prevent spoofing, standard HTTP/HTTPS URLs with non-empty hostnames are
+ // head-elided, matching desktop elision behavior. All other schemes (local,
+ // chrome://, etc.) are tail-elided so their scheme prefix remains visible.
+ boolean isHttpOrHttps = openerUrl != null && UrlUtilities.isHttpOrHttps(openerUrl);
mModel.set(
DocumentPictureInPictureHeaderProperties.URL_ELLIPSIZE_BEHAVIOR,
- isLocalFileOrContentScheme(visibleUrl.getScheme())
- ? TextUtils.TruncateAt.END
- : TextUtils.TruncateAt.START);
+ (isHttpOrHttps && !openerUrl.getHost().isEmpty())
+ ? TextUtils.TruncateAt.START
+ : TextUtils.TruncateAt.END);
mThemeColorProvider.addThemeColorObserver(this);
mThemeColorProvider.addTintObserver(this);
@@ -283,23 +284,25 @@
DocumentPictureInPictureHeaderProperties.NON_DRAGGABLE_AREAS, mNonDraggableAreas);
}
- private boolean isLocalFileOrContentScheme(@Nullable String scheme) {
- return UrlConstants.FILE_SCHEME.equals(scheme)
- || UrlConstants.CONTENT_SCHEME.equals(scheme);
- }
+ private String getUrlString(@Nullable GURL url) {
+ if (url == null || url.isEmpty() || !url.isValid()) {
+ return "";
+ }
- private String getUrlString(GURL url) {
- if (url.getScheme().equals(UrlConstants.FILE_SCHEME)) {
+ final String scheme = url.getScheme();
+ if (UrlConstants.FILE_SCHEME.equals(scheme)) {
// File scheme URLs do not have a host, so we use the path instead.
return url.getPath();
}
- if (url.getHost().isEmpty()) {
- Log.w(TAG, "URL has an empty host, falling back to the full URL spec.");
- // Fallback to the full URL spec if the host is empty.
+ if (UrlConstants.CONTENT_SCHEME.equals(scheme)) {
return url.getSpec();
}
+ if (url.getHost().isEmpty()) {
+ return "";
+ }
+
return UrlFormatter.formatUrlForSecurityDisplay(url, SchemeDisplay.OMIT_HTTP_AND_HTTPS);
}
diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivityTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivityTest.java
index e10edaa..56a6e86 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivityTest.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivityTest.java
@@ -249,7 +249,7 @@
@Test
@MediumTest
- public void testOpaqueOriginDoesNotExit() throws Exception {
+ public void testOpaqueOriginFinishesActivity() throws Exception {
// Navigate the parent tab to a data URL (which results in an opaque origin).
final String dataUrl = "data:text/html,<html><body>Hello</body></html>";
ChromeTabUtils.waitForTabPageLoaded(
@@ -266,16 +266,10 @@
mParentWebContents, mWebContents);
});
- // Launch the PiP activity. Its verifyOpenerOrigin() will check the origin of
- // mParentWebContents
- // (which is now opaque, serializing to "null") against the intent's initial opener origin
- // (which we also pass as "null").
+ // Launch the PiP activity with an opaque initial opener origin.
+ // verifyOpenerOrigin() must reject opaque origins and immediately finish itself.
DocumentPictureInPictureActivity activity = launchActivity("null");
- // Wait for startup to complete and verify it does NOT finish.
- CriteriaHelper.pollUiThread(() -> !activity.isFinishing());
-
- // Clean up.
- ThreadUtils.runOnUiThreadBlocking(activity::finish);
+ CriteriaHelper.pollUiThread(() -> activity.isFinishing() || activity.isDestroyed());
}
}
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImplUnitTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImplUnitTest.java
index 65a83f10..faada82 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImplUnitTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/app/tab_activity_glue/PopupCreatorImplUnitTest.java
@@ -71,11 +71,14 @@
import org.chromium.chrome.browser.util.AndroidTaskUtils;
import org.chromium.chrome.browser.util.PictureInPictureWindowOptions;
import org.chromium.chrome.browser.util.WindowFeatures;
+import org.chromium.content_public.browser.RenderFrameHost;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.ActivityWindowAndroid;
import org.chromium.ui.display.DisplayAndroid;
import org.chromium.ui.display.DisplayAndroidManager;
import org.chromium.ui.insets.InsetObserver;
+import org.chromium.url.GURL;
+import org.chromium.url.Origin;
/** Unit test for {@link PopupCreatorImpl}. */
@RunWith(BaseRobolectricTestRunner.class)
@@ -525,6 +528,103 @@
}
@Test
+ public void testMoveWebContentsToNewDocPipWindow_setsInitialOpenerOriginForTupleOrigin() {
+ ContextUtils.initApplicationContextForTests(mContext);
+ final PictureInPictureWindowOptions windowOptions = new PictureInPictureWindowOptions();
+
+ WebContents opener = mock(WebContents.class);
+ RenderFrameHost openerFrame = mock(RenderFrameHost.class);
+ Origin origin = Origin.create(new GURL("https://example.com"));
+ when(mWebContents.getDocumentPictureInPictureOpener()).thenReturn(opener);
+ when(opener.getMainFrame()).thenReturn(openerFrame);
+ when(openerFrame.getLastCommittedOrigin()).thenReturn(origin);
+
+ mPopupCreator.moveWebContentsToNewDocumentPictureInPictureWindow(
+ null, mWebContents, windowOptions);
+
+ ArgumentCaptor<Intent> captor = ArgumentCaptor.forClass(Intent.class);
+ verify(mContext).startActivity(captor.capture(), any());
+ Intent sentIntent = captor.getValue();
+
+ assertEquals(
+ origin.toString(),
+ sentIntent.getStringExtra(
+ DocumentPictureInPictureActivity.INITIAL_OPENER_ORIGIN_KEY));
+ }
+
+ @Test
+ public void testMoveWebContentsToNewDocPipWindow_omitsInitialOpenerOriginForOpaqueOrigin() {
+ ContextUtils.initApplicationContextForTests(mContext);
+ final PictureInPictureWindowOptions windowOptions = new PictureInPictureWindowOptions();
+
+ WebContents opener = mock(WebContents.class);
+ RenderFrameHost openerFrame = mock(RenderFrameHost.class);
+ Origin opaqueOrigin = Origin.createOpaqueOrigin();
+ when(mWebContents.getDocumentPictureInPictureOpener()).thenReturn(opener);
+ when(opener.getMainFrame()).thenReturn(openerFrame);
+ when(openerFrame.getLastCommittedOrigin()).thenReturn(opaqueOrigin);
+
+ mPopupCreator.moveWebContentsToNewDocumentPictureInPictureWindow(
+ null, mWebContents, windowOptions);
+
+ ArgumentCaptor<Intent> captor = ArgumentCaptor.forClass(Intent.class);
+ verify(mContext).startActivity(captor.capture(), any());
+ Intent sentIntent = captor.getValue();
+
+ assertEquals(
+ "",
+ sentIntent.getStringExtra(
+ DocumentPictureInPictureActivity.INITIAL_OPENER_ORIGIN_KEY));
+ }
+
+ @Test
+ public void testMoveWebContentsToNewDocPipWindow_fallsBackToLastCommittedUrl() {
+ ContextUtils.initApplicationContextForTests(mContext);
+ final PictureInPictureWindowOptions windowOptions = new PictureInPictureWindowOptions();
+
+ WebContents opener = mock(WebContents.class);
+ GURL url = new GURL("https://example.com");
+ when(mWebContents.getDocumentPictureInPictureOpener()).thenReturn(opener);
+ when(opener.getMainFrame()).thenReturn(null);
+ when(opener.getLastCommittedUrl()).thenReturn(url);
+
+ mPopupCreator.moveWebContentsToNewDocumentPictureInPictureWindow(
+ null, mWebContents, windowOptions);
+
+ ArgumentCaptor<Intent> captor = ArgumentCaptor.forClass(Intent.class);
+ verify(mContext).startActivity(captor.capture(), any());
+ Intent sentIntent = captor.getValue();
+
+ assertEquals(
+ Origin.create(url).toString(),
+ sentIntent.getStringExtra(
+ DocumentPictureInPictureActivity.INITIAL_OPENER_ORIGIN_KEY));
+ }
+
+ @Test
+ public void testMoveWebContentsToNewDocPipWindow_nullOpenerUrlDoesNotSetExtra() {
+ ContextUtils.initApplicationContextForTests(mContext);
+ final PictureInPictureWindowOptions windowOptions = new PictureInPictureWindowOptions();
+
+ WebContents opener = mock(WebContents.class);
+ when(mWebContents.getDocumentPictureInPictureOpener()).thenReturn(opener);
+ when(opener.getMainFrame()).thenReturn(null);
+ when(opener.getLastCommittedUrl()).thenReturn(null);
+
+ mPopupCreator.moveWebContentsToNewDocumentPictureInPictureWindow(
+ null, mWebContents, windowOptions);
+
+ ArgumentCaptor<Intent> captor = ArgumentCaptor.forClass(Intent.class);
+ verify(mContext).startActivity(captor.capture(), any());
+ Intent sentIntent = captor.getValue();
+
+ assertEquals(
+ "",
+ sentIntent.getStringExtra(
+ DocumentPictureInPictureActivity.INITIAL_OPENER_ORIGIN_KEY));
+ }
+
+ @Test
public void testTryStartActivity_success() {
final Intent intent = mock(Intent.class);
final Bundle ao = new Bundle();
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivityUnitTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivityUnitTest.java
index 00d6ca8..5a22b64d 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivityUnitTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivityUnitTest.java
@@ -18,6 +18,7 @@
import static org.mockito.Mockito.when;
import android.app.ActivityManager.AppTask;
+import android.content.Intent;
import android.graphics.Rect;
import android.os.Build;
import android.view.WindowManager;
@@ -43,10 +44,13 @@
import org.chromium.chrome.browser.profiles.ProfileProvider;
import org.chromium.chrome.browser.util.AndroidTaskUtils;
import org.chromium.chrome.browser.util.PictureInPictureWindowOptions;
+import org.chromium.content_public.browser.RenderFrameHost;
... (file diff truncated)
Original Bug Report
URL spoof in Document PiP header via empty-host fallback and head-elision
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The Android Document Picture-in-Picture header falls back to displaying the full URL spec when the opener URL has an empty host, such as for about:blank#<fragment> URLs. When combined with single-line START (head) elision, the about:blank# prefix is hidden while an attacker-chosen URL tail remains visible in the header. This may allow an attacker to display a spoofed origin attribution in the browser-drawn, always-on-top Document PiP window.
Affected files:
chrome/android/java/src/org/chromium/chrome/browser/media/document_picture_in_picture_header/DocumentPictureInPictureHeaderMediator.javachrome/android/java/src/org/chromium/chrome/browser/media/DocumentPictureInPictureActivity.java
Estimated timestamp from git blame: 2026-05-20
Summary
A potential UI origin spoofing vulnerability exists in the Android Document Picture-in-Picture (PiP) header component. When displaying the URL of the opener WebContents, DocumentPictureInPictureHeaderMediator.getUrlString() falls back to rendering the entire raw URL spec (url.getSpec()) if the opener URL has an empty host string. Combined with START (head) elision on the single-line URL TextView, an attacker can use about:blank#<padding><target_url> to elide the about:blank# prefix and display an arbitrary target URL tail in the trusted browser header.
Note: The steps and reproduction sequence described below are based on static code analysis; tooling has not executed live code on a physical device.
Technical Details & Root Cause
- Empty Host Fallback:
In
chrome/android/java/src/org/chromium/chrome/browser/media/document_picture_in_picture_header/DocumentPictureInPictureHeaderMediator.java(lines 294-307):
private String getUrlString(GURL url) {
if (url.getScheme().equals(UrlConstants.FILE_SCHEME)) {
return url.getPath();
}
if (url.getHost().isEmpty()) {
Log.w(TAG, "URL has an empty host, falling back to the full URL spec.");
// Fallback to the full URL spec if the host is empty.
return url.getSpec();
}
return UrlFormatter.formatUrlForSecurityDisplay(url, SchemeDisplay.OMIT_HTTP_AND_HTTPS);
}
For non-special schemes such as about:, url.getHost() is empty (""). Calling url.getSpec() returns the full URL string including the fragment, which is attacker-controlled.
- Head (START) Elision:
In
DocumentPictureInPictureHeaderMediator.java(lines 129-135):
mModel.set(
DocumentPictureInPictureHeaderProperties.URL_ELLIPSIZE_BEHAVIOR,
isLocalFileOrContentScheme(visibleUrl.getScheme())
? TextUtils.TruncateAt.END
: TextUtils.TruncateAt.START);
Non-file/content URLs (including about:) are assigned TextUtils.TruncateAt.START. In chrome/android/java/res/layout/document_picture_in_picture_header_layout.xml (lines 33-49), the TextView is configured with singleLine="true" and ellipsize="start". When the string length exceeds the available header width, the beginning (about:blank#///...) is clipped and replaced by …, leaving only the attacker-crafted suffix visible (e.g., …https://victim-bank.com/login).
-
Opener Verification Bypass for Opaque Origins: In
DocumentPictureInPictureActivity.java(lines 880-901),verifyOpenerOrigincompares the launch intent’sINITIAL_OPENER_ORIGIN_KEYagainstOrigin.create(parentWebContents.getLastCommittedUrl()).toString(). Forabout:blank, nativeOrigin::Creategenerates an opaque origin whosetoString()evaluates to"null"(url/android/java/src/org/chromium/url/Origin.java:141-146). Because both sides evaluate to"null","null".equals("null")returnstrueand the check passes. -
Desktop Comparison: On Desktop (
chrome/browser/ui/views/frame/picture_in_picture_browser_frame_view.cc:692-702),ShouldPreventElision()returnsfalseandShouldTrimDisplayUrlAfterHostName()returnstrue(enforcing eTLD+1), explicitly preventing full URL specs or fragments from appearing in the PiP header.
Potential Trigger Steps
- User visits an attacker-controlled site (
https://attacker.com) on an Android 16+ device with desktop-windowing / pinned-windowing support andDocumentPictureInPictureAPIenabled. - User clicks on the page, granting user activation.
- Attacker executes
const popup = window.open('about:blank#' + '/'.repeat(160) + 'https://victim-bank.com/login');. - Attacker injects a full-window button into the popup (
popup.document.write(...)). - User clicks the button in the popup, granting transient activation to the popup.
- Popup executes
documentPictureInPicture.requestWindow({width: 240, height: 200}). DocumentPictureInPictureActivitylaunches.verifyOpenerOriginmatches"null" == "null".- The header mediator sets the URL string to the full spec (
about:blank#///...https://victim-bank.com/login). - Due to START elision and single-line constraints, the header displays
…https://victim-bank.com/loginalongside a neutral security icon over attacker-scripted content.
Suggested Fix
- Do not fall back to
url.getSpec()for empty-host URLs inDocumentPictureInPictureHeaderMediator.getUrlString(). Derive the displayed string from the opener’s origin rather than the raw URL spec. - For opaque origins (such as
about:blank), display a safe fixed placeholder or fallback label matching desktopLocationBarModelorigin-display conventions. - Ensure non-standard schemes are properly formatted or tail-elided rather than head-elided when path/spec fallbacks are rendered.
Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.