Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in Session
DescriptionUI misrepresentation in Session
ComponentSession
Bug ClassLogic Error
Tracker514056835
Fix commitacc73923fea2 (chromium/src) +82/-35
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
ASSERT_TRUE
chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
modified

Files Changed

  • chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
  • content/browser/media/session/media_session_impl.cc
From acc73923fea2710e74d6c5d3b896ba295f8a22c5 Mon Sep 17 00:00:00 2001
From: Benjamin Keen <[email protected]>
Date: Tue, 04 Aug 2026 14:00:07 -0700
Subject: [PATCH] Compute source_title from the committed origin of the primary main frame

`BuildMetadata()` previously derived the source origin from the
committed URL. For about:blank documents, this created an opaque origin
and fell back to reading the opener's current URL, which could be
spoofed if the opener navigated cross-origin.

This CL fixes this by using the committed origin of the primary main
frame directly, which handles about:blank inheritance and sandboxed
precursors, allowing the opener-chain fallback to be removed.

Bug: 514056835
Change-Id: Ia489373461d54591fe1df8af3e7071941f80d9b9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8191294
Commit-Queue: Benjamin Keen <[email protected]>
Reviewed-by: Tommy Steimel <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1673651}
---

diff --git a/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc b/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
index ce23ee9c..b0c50c4b 100644
--- a/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
+++ b/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
@@ -2565,7 +2565,7 @@
   ASSERT_TRUE(ExecJs(popup_contents, script));
 
   // Wait until the Picture-in-Picture window is visible and its source title
-  // correctly falls back to the opener's origin (example.com).
+  // reflects the precursor origin of the opaque sandboxed frame (example.com).
   SetUpWindowController(popup_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2622,8 +2622,7 @@
   ASSERT_TRUE(ExecJs(popup2_contents, script));
 
   // Wait until the Picture-in-Picture window is visible and its source title
-  // correctly falls back through the nested openers to the original origin
-  // (example.com).
+  // reflects the inherited precursor origin of the nested popups (example.com).
   SetUpWindowController(popup2_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2635,7 +2634,7 @@
 }
 
 IN_PROC_BROWSER_TEST_F(VideoPictureInPictureWindowControllerBrowserTest,
-                       SourceTitle_ClosestAncestorFallback) {
+                       SourceTitle_InheritedFromNavigatedOpener) {
   const std::string kHost1 = "example.com";
   const std::string kHost2 = "another-site.com";
   const std::u16string kExpectedTitlePrefix = base::ASCIIToUTF16(kHost2);
@@ -2691,7 +2690,8 @@
       {video_url.spec()}, nullptr);
   ASSERT_TRUE(ExecJs(popup2_contents, script));
 
-  // Verify source title is Host 2 (the closest opener with a valid precursor).
+  // Verify the source title reflects the opener's origin at the time of the
+  // popup's creation (another-site.com).
   SetUpWindowController(popup2_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2702,6 +2702,76 @@
   }));
 }
 
+IN_PROC_BROWSER_TEST_F(VideoPictureInPictureWindowControllerBrowserTest,
+                       SourceTitle_AboutBlankPopupUsesInheritedOrigin) {
+  const std::string kHost1 = "example.com";
+  const std::string kHost2 = "another-site.com";
+  const std::u16string kExpectedTitlePrefix = base::ASCIIToUTF16(kHost1);
+
+  // Open a non-sandboxed page on Host 1.
+  GURL url1 = embedded_test_server()->GetURL(kHost1, "/title1.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url1));
+  content::WebContents* active_web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+
+  // Open an about:blank popup. The popup's committed URL is about:blank, but
+  // its committed origin is inherited from Host 1.
+  content::WebContents* popup_contents;
+  {
+    content::WebContentsAddedObserver observer;
+    ASSERT_TRUE(ExecJs(active_web_contents, "window.open('about:blank');"));
+    popup_contents = observer.GetWebContents();
+  }
+  ASSERT_FALSE(
+      popup_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin().opaque());
+  ASSERT_EQ(url::Origin::Create(url1),
+            popup_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin());
+
+  // Navigate the opener cross-origin to Host 2. The popup keeps its inherited
+  // Host 1 origin and its opener relationship.
+  GURL url2 = embedded_test_server()->GetURL(kHost2, "/title1.html");
+  {
+    content::TestNavigationObserver nav_observer(active_web_contents);
+    ASSERT_TRUE(ExecJs(active_web_contents,
+                       base::StringPrintf("window.location.href = '%s';",
+                                          url2.spec().c_str())));
+    nav_observer.Wait();
+  }
+  ASSERT_EQ(active_web_contents->GetPrimaryMainFrame(),
+            popup_contents->GetOpener());
+
+  // Play video in the popup and request Picture-in-Picture.
+  GURL video_url = embedded_test_server()->GetURL(kHost1, "/media/bear.webm");
+  std::string script = base::ReplaceStringPlaceholders(
+      R"(
+        const video = document.createElement('video');
+        video.src = '$1';
+        video.loop = true;
+        document.body.appendChild(video);
+        video.play().then(() => video.requestPictureInPicture());
+      )",
+      {video_url.spec()}, nullptr);
+  ASSERT_TRUE(ExecJs(popup_contents, script));
+
+  // Wait until the Picture-in-Picture window is visible and its source title
+  // has been populated.
+  SetUpWindowController(popup_contents);
+  ASSERT_TRUE(base::test::RunUntil([&]() {
+    auto* overlay_window = GetOverlayWindow();
+    return overlay_window && overlay_window->IsVisible() &&
+           overlay_window->origin_for_testing() &&
+           !overlay_window->origin_for_testing()->GetText().empty();
+  }));
+
+  // The source title must reflect the popup's own (inherited) origin, not the
+  // opener's current origin.
+  EXPECT_TRUE(
+      base::StartsWith(GetOverlayWindow()->origin_for_testing()->GetText(),
+                       kExpectedTitlePrefix))
+      << "source title is '"
+      << GetOverlayWindow()->origin_for_testing()->GetText() << "'";
+}
+
 struct InteractionTestParam {
   ui::EventType event_type;
   bool title_should_be_visible;
diff --git a/content/browser/media/session/media_session_impl.cc b/content/browser/media/session/media_session_impl.cc
index a4580de..297a5049 100644
--- a/content/browser/media/session/media_session_impl.cc
+++ b/content/browser/media/session/media_session_impl.cc
@@ -8,7 +8,6 @@
 #include <memory>
 #include <utility>
 
-#include "base/containers/flat_set.h"
 #include "base/functional/bind.h"
 #include "base/strings/string_util.h"
 #include "base/strings/utf_string_conversions.h"
@@ -2063,35 +2062,13 @@
       source_title =
           content_client->GetLocalizedString(IDS_MEDIA_SESSION_DATA_SOURCE);
     } else {
-      url::Origin origin = url::Origin::Create(url);
-      GURL format_url = origin.GetURL();
-
-      // If the origin is opaque, use its precursor origin if available.
-      // Otherwise, traverse the opener chain to find the closest ancestor with
-      // a valid precursor origin. This ensures we display a recognizable origin
-      // to the user.
-      if (origin.opaque()) {
-        WebContents* current_web_contents = web_contents();
-        base::flat_set<WebContents*> seen_web_contents;
-
-        while (current_web_contents &&
-               !seen_web_contents.contains(current_web_contents)) {
-          seen_web_contents.insert(current_web_contents);
-
-          url::Origin current_origin =
-              url::Origin::Create(current_web_contents->GetLastCommittedURL());
-          const auto& precursor =
-              current_origin.GetTupleOrPrecursorTupleIfOpaque();
-          if (precursor.IsValid()) {
-            format_url = precursor.GetURL();
-            break;
-          }
-
-          RenderFrameHost* opener = current_web_contents->GetOpener();
-          current_web_contents =
-              opener ? WebContents::FromRenderFrameHost(opener) : nullptr;
-        }
-      }
+      // Use the frame's committed origin to determine the source title.
+      // This retrieves the inherited origin for "about:blank" documents
+      // (which is the origin of their creator) and the precursor origin for
+      // sandboxed documents (which have opaque origins).
+      const url::Origin& origin =
+          web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin();
+      GURL format_url = origin.GetTupleOrPrecursorTupleIfOpaque().GetURL();
 
       source_title = url_formatter::FormatUrl(
           format_url,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc b/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
index ce23ee9c..b0c50c4b 100644
--- a/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
+++ b/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
@@ -2565,7 +2565,7 @@
   ASSERT_TRUE(ExecJs(popup_contents, script));
 
   // Wait until the Picture-in-Picture window is visible and its source title
-  // correctly falls back to the opener's origin (example.com).
+  // reflects the precursor origin of the opaque sandboxed frame (example.com).
   SetUpWindowController(popup_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2622,8 +2622,7 @@
   ASSERT_TRUE(ExecJs(popup2_contents, script));
 
   // Wait until the Picture-in-Picture window is visible and its source title
-  // correctly falls back through the nested openers to the original origin
-  // (example.com).
+  // reflects the inherited precursor origin of the nested popups (example.com).
   SetUpWindowController(popup2_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2635,7 +2634,7 @@
 }
 
 IN_PROC_BROWSER_TEST_F(VideoPictureInPictureWindowControllerBrowserTest,
-                       SourceTitle_ClosestAncestorFallback) {
+                       SourceTitle_InheritedFromNavigatedOpener) {
   const std::string kHost1 = "example.com";
   const std::string kHost2 = "another-site.com";
   const std::u16string kExpectedTitlePrefix = base::ASCIIToUTF16(kHost2);
@@ -2691,7 +2690,8 @@
       {video_url.spec()}, nullptr);
   ASSERT_TRUE(ExecJs(popup2_contents, script));
 
-  // Verify source title is Host 2 (the closest opener with a valid precursor).
+  // Verify the source title reflects the opener's origin at the time of the
+  // popup's creation (another-site.com).
   SetUpWindowController(popup2_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2702,6 +2702,76 @@
   }));
 }
 
+IN_PROC_BROWSER_TEST_F(VideoPictureInPictureWindowControllerBrowserTest,
+                       SourceTitle_AboutBlankPopupUsesInheritedOrigin) {
+  const std::string kHost1 = "example.com";
+  const std::string kHost2 = "another-site.com";
+  const std::u16string kExpectedTitlePrefix = base::ASCIIToUTF16(kHost1);
+
+  // Open a non-sandboxed page on Host 1.
+  GURL url1 = embedded_test_server()->GetURL(kHost1, "/title1.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url1));
+  content::WebContents* active_web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+
+  // Open an about:blank popup. The popup's committed URL is about:blank, but
+  // its committed origin is inherited from Host 1.
+  content::WebContents* popup_contents;
+  {
+    content::WebContentsAddedObserver observer;
+    ASSERT_TRUE(ExecJs(active_web_contents, "window.open('about:blank');"));
+    popup_contents = observer.GetWebContents();
+  }
+  ASSERT_FALSE(
+      popup_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin().opaque());
+  ASSERT_EQ(url::Origin::Create(url1),
+            popup_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin());
+
+  // Navigate the opener cross-origin to Host 2. The popup keeps its inherited
+  // Host 1 origin and its opener relationship.
+  GURL url2 = embedded_test_server()->GetURL(kHost2, "/title1.html");
+  {
+    content::TestNavigationObserver nav_observer(active_web_contents);
+    ASSERT_TRUE(ExecJs(active_web_contents,
+                       base::StringPrintf("window.location.href = '%s';",
+                                          url2.spec().c_str())));
+    nav_observer.Wait();
+  }
+  ASSERT_EQ(active_web_contents->GetPrimaryMainFrame(),
+            popup_contents->GetOpener());
+
+  // Play video in the popup and request Picture-in-Picture.
+  GURL video_url = embedded_test_server()->GetURL(kHost1, "/media/bear.webm");
+  std::string script = base::ReplaceStringPlaceholders(
+      R"(
+        const video = document.createElement('video');
+        video.src = '$1';
+        video.loop = true;
+        document.body.appendChild(video);
+        video.play().then(() => video.requestPictureInPicture());
+      )",
+      {video_url.spec()}, nullptr);
+  ASSERT_TRUE(ExecJs(popup_contents, script));
+
+  // Wait until the Picture-in-Picture window is visible and its source title
+  // has been populated.
+  SetUpWindowController(popup_contents);
+  ASSERT_TRUE(base::test::RunUntil([&]() {
+    auto* overlay_window = GetOverlayWindow();
+    return overlay_window && overlay_window->IsVisible() &&
+           overlay_window->origin_for_testing() &&
+           !overlay_window->origin_for_testing()->GetText().empty();
+  }));
+
+  // The source title must reflect the popup's own (inherited) origin, not the
+  // opener's current origin.
+  EXPECT_TRUE(
+      base::StartsWith(GetOverlayWindow()->origin_for_testing()->GetText(),
+                       kExpectedTitlePrefix))
+      << "source title is '"
+      << GetOverlayWindow()->origin_for_testing()->GetText() << "'";
+}
+
 struct InteractionTestParam {
   ui::EventType event_type;
   bool title_should_be_visible;
Loading diff…

Original Bug Report

reported by [email protected]

Origin Spoofing in Media Controls via insecure opener URL traversal

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: MediaSessionImpl::BuildMetadata incorrectly identifies the source origin for about:blank popups by traversing the window opener chain and using the opener’s current URL. An attacker can exploit this to spoof a victim site’s origin in Global Media Controls and Picture-in-Picture overlays.

Affected files:

  • content/browser/media/session/media_session_impl.cc

Estimated timestamp from git blame: 2026-01-23

The MediaSessionImpl::BuildMetadata function determines the source_title (origin label) displayed in trusted browser UI components, such as Global Media Controls (GMC), Picture-in-Picture (PiP) window labels, and the ChromeOS lock screen.

A logic vulnerability exists in how this origin is calculated for pages with opaque origins, such as about:blank popups. The implementation currently creates a temporary origin from the frame’s URL string using url::Origin::Create(web_contents()->GetLastCommittedURL()). For about:blank, this results in an opaque origin that lacks precursor information. To find a displayable origin, the code then enters a loop that follows the popup’s opener chain using GetOpener() and evaluates each ancestor’s current last committed URL.

Because the window opener relationship persists across cross-origin navigations (unless restricted by COOP), an attacker can open a popup, navigate their own main window to a target victim site, and then update the popup’s media metadata. The browser will traverse the opener chain, see the victim site’s current URL, and use it as the source label for the attacker-controlled media.

Potential Attack Scenario

  1. A user visits https://attacker.com, which opens a popup to about:blank via window.open().
  2. The popup starts media playback (e.g., a hidden audio element) and sets navigator.mediaSession.metadata.
  3. The original attacker window navigates itself to https://trusted-site.com.
  4. The popup updates its metadata with phishing content (e.g., “Security Alert: Sign-in Required”).
  5. When MediaSessionImpl::BuildMetadata runs, it traverses the opener chain from the about:blank popup. It reaches the opener window, sees its current URL is https://trusted-site.com, and uses this origin as the source_title.
  6. Trusted browser surfaces like GMC and PiP overlays display “trusted-site.com” as the source of the attacker’s media and metadata.

Replace the use of url::Origin::Create(web_contents()->GetLastCommittedURL()) and the subsequent manual opener traversal loop with a direct call to the frame’s security origin: web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin(). This returns the correctly inherited security origin for about:blank popups, which already contains the proper precursor origin (the origin that launched the popup), preventing spoofing based on the opener’s current state.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


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.

View on issue tracker