Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Cast
DescriptionInsufficient validation of untrusted input in Cast
ComponentCast
Bug ClassLogic Error
Tracker513232523
Fix commit16cbed6b75c8 (chromium/src) +407/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
TEST_F
chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
modified
for
chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
modified
if
chrome/browser/ui/media_router/presentation_receiver_window_controller.cc
modified

Files Changed

  • chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
  • chrome/browser/ui/media_router/presentation_receiver_window_controller.cc
  • chrome/browser/ui/media_router/presentation_receiver_window_controller.h
From 16cbed6b75c8bcdd7beafddecae84db7065f38eb Mon Sep 17 00:00:00 2001
From: mark a. foltz <mfoltz@chromium.org>
Date: Mon, 08 Jun 2026 17:49:05 -0700
Subject: [PATCH] Fix three security vulnerabilities in the Presentation API.

This CL resolves three distinct security issues identified in the
Presentation API implementation across Blink and the browser process:

1. Allowlist Bypass (components/media_router):
   - Bug: IsSiteInitiatedMirroringSource() only matched the standard
     Cast Streaming audio+video app ID, allowing site-initiated
     tab-mirroring requests with alternative streaming app IDs
     (e.g. audio-only) or legacy URL formats to bypass the
     kPresentationApiAllowlist check.
   - Fix: Updated the predicate to match any Cast Presentation URL for
     which ContainsStreamingApp() is true. This ensures the allowlist
     is correctly applied to all mirroring sources.
   - Test: Added
     CastMediaRouteProviderTest.PresentationApiMirroringOriginAllowlist.

2. Cross-Origin Connection Hijacking (chrome/browser/ui/media_router):
   - Bug: When a receiver page navigated cross-origin, the navigation
     policy rightly disallowed it and called Terminate() which
     asynchronously closed the page. However, the NavigationHandle was
     not aborted, allowing the cross-origin page to commit. The new
     document could then call SetReceiver and hijack the pending
     PresentationConnection.
   - Fix: Asynchronously stop the navigation and terminate the
     receiver window upon detecting a disallowed navigation. Added
     origin validation to LocalPresentationManager to ensure
     subsequent registrations for a presentation ID match the origin
     of the first registration. This prevents cross-origin hijacking
     while allowing same-origin redirects and offscreen tab test
     configurations (such as in
     MediaRouterIntegrationOneUANoReceiverBrowserTest).
   - Test: Added PresentationReceiverNavigationBrowserTest.
     CrossOriginNavigationDoesNotCommit.

3. Out-of-Slice Heap Data Leak (third_party/blink):
   - Bug: PresentationConnection::send() for an ArrayBufferView
     enqueued the entire backing DOMArrayBuffer instead of the
     specified view slice, leaking out-of-slice heap data from the
     renderer's linear memory to the receiver.
   - Fix: Transmit only the view's slice by copying it into a new
     DOMArrayBuffer using DOMArrayBuffer::Create(
     array_buffer_view->ByteSpan()).
   - Test: Added
     PresentationConnectionTest.SendArrayBufferViewSendsOnlySlice.

Fixed: 513363822,513232523,501980797
Test: CastMediaRouteProviderTest.PresentationApiMirroringOriginAllowlist, PresentationReceiverNavigationBrowserTest.CrossOriginNavigationDoesNotCommit, PresentationConnectionTest.SendArrayBufferViewSendsOnlySlice
Change-Id: If0da3e414aeccd7de916453b96a4e8635951a825
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7908443
Commit-Queue: Mark Foltz <mfoltz@chromium.org>
Reviewed-by: Muyao Xu <muyaoxu@google.com>
Cr-Commit-Position: refs/heads/main@{#1643569}
---

diff --git a/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc b/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
index efbe710..b208ee7 100644
--- a/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
+++ b/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
@@ -34,6 +34,7 @@
 using ::testing::_;
 using testing::Mock;
 using ::testing::NiceMock;
+using ::testing::SaveArg;
 using testing::WithArg;
 
 namespace media_router {
@@ -153,6 +154,26 @@
     base::RunLoop().RunUntilIdle();
   }
 
+  // POC helper: invokes the private OnSinkQueryUpdated()
+  // (CastMediaRouteProvider friends this fixture class) and returns the
+  // |origins| that were forwarded to MediaRouter::OnSinksReceived -- i.e. the
+  // result of GetOrigins().
+  std::vector<url::Origin> GetOnSinksReceivedOrigins(
+      const MediaSource::Id& source_id,
+      const std::vector<MediaSinkInternal>& sinks) {
+    std::vector<url::Origin> captured_origins;
+    base::RunLoop run_loop;
+    EXPECT_CALL(mock_router_, OnSinksReceived(mojom::MediaRouteProviderId::CAST,
+                                              source_id, sinks, _))
+        .WillOnce(
+            testing::DoAll(SaveArg<3>(&captured_origins),
+                           base::test::RunOnceClosure(run_loop.QuitClosure())));
+    provider_->OnSinkQueryUpdated(source_id, sinks);
+    run_loop.Run();
+    Mock::VerifyAndClearExpectations(&mock_router_);
+    return captured_origins;
+  }
+
   void UpdateSinkQueryAndExpectSinkReceived(
       const std::vector<MediaSinkInternal>& expected_received_sinks,
       const MediaSource::Id& source_id,
@@ -201,6 +222,43 @@
   EXPECT_TRUE(app_discovery_service_.callbacks().empty());
 }
 
+TEST_F(CastMediaRouteProviderTest, PresentationApiMirroringOriginAllowlist) {
+  struct Case {
+    const char* name;
+    const char* source_id;
+  } const cases[] = {
+      {"video", "cast:0F5096E8?clientId=1"},
+      {"audio-only", "cast:85CDB22F?clientId=1"},
+      {"legacy-url",
+       "https://google.com/cast#__castAppId__=0F5096E8/__castClientId__=1"},
+  };
+
+  const MediaSinkInternal sink = CreateCastSink(1);
+  const std::vector<MediaSinkInternal> sinks = {sink};
+
+  for (const auto& c : cases) {
+    // The IsCastPresentationUrl + ContainsStreamingApp combination is exactly
+    // what GetMirroringType() uses to select MirroringType::kTab.
+    EXPECT_TRUE(MediaSource(c.source_id).IsCastPresentationUrl());
+
+    // All three sources are routed as tab-mirroring by CastActivityManager
+    // (DoLaunchSession -> ContainsStreamingApp() -> AddMirroringActivity).
+    auto cast_source = CastMediaSource::FromMediaSourceId(c.source_id);
+    ASSERT_TRUE(cast_source);
+    EXPECT_TRUE(cast_source->ContainsStreamingApp())
+        << c.source_id << " is treated as a Cast Streaming (mirroring) app";
+
+    // OnSinkQueryUpdated computes GetOrigins(source_id) and forwards it to
+    // MediaRouter::OnSinksReceived.
+    std::vector<url::Origin> captured_origins =
+        GetOnSinksReceivedOrigins(c.source_id, sinks);
+
+    // kPresentationApiAllowlist is applied, restricting these sources to
+    // trusted origins.
+    EXPECT_EQ(captured_origins.size(), 3u);
+  }
+}
+
 TEST_F(CastMediaRouteProviderTest, CreateRouteFailsInvalidSink) {
   // Sink does not exist.
   provider_->CreateRoute(
diff --git a/chrome/browser/ui/media_router/presentation_receiver_window_controller.cc b/chrome/browser/ui/media_router/presentation_receiver_window_controller.cc
index a05510ea..febdb4d 100644
--- a/chrome/browser/ui/media_router/presentation_receiver_window_controller.cc
+++ b/chrome/browser/ui/media_router/presentation_receiver_window_controller.cc
@@ -10,6 +10,7 @@
 #include "base/functional/bind.h"
 #include "base/memory/ptr_util.h"
 #include "base/strings/utf_string_conversions.h"
+#include "base/task/single_thread_task_runner.h"
 #include "chrome/browser/profiles/profile.h"
 #include "chrome/browser/profiles/profile_destroyer.h"
 #include "chrome/browser/ui/media_router/presentation_receiver_window.h"
@@ -144,10 +145,20 @@
 void PresentationReceiverWindowController::DidStartNavigation(
     content::NavigationHandle* handle) {
   if (!navigation_policy_.AllowNavigation(handle)) {
-    Terminate();
+    base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+        FROM_HERE,
+        base::BindOnce(&PresentationReceiverWindowController::StopAndTerminate,
+                       weak_factory_.GetWeakPtr()));
   }
 }
 
+void PresentationReceiverWindowController::StopAndTerminate() {
+  if (web_contents_) {
+    web_contents_->Stop();
+  }
+  Terminate();
+}
+
 void PresentationReceiverWindowController::TitleWasSet(
     content::NavigationEntry* entry) {
   window_->UpdateWindowTitle();
diff --git a/chrome/browser/ui/media_router/presentation_receiver_window_controller.h b/chrome/browser/ui/media_router/presentation_receiver_window_controller.h
index 3fc06be..5e440426 100644
--- a/chrome/browser/ui/media_router/presentation_receiver_window_controller.h
+++ b/chrome/browser/ui/media_router/presentation_receiver_window_controller.h
@@ -87,6 +87,8 @@
   // PresentationReceiverWindowDelegate overrides.
   void WindowClosed() final;
 
+  void StopAndTerminate();
+
   // content::WebContentsObserver overrides.
   void DidStartNavigation(content::NavigationHandle* handle) final;
   void TitleWasSet(content::NavigationEntry* entry) final;
@@ -126,6 +128,9 @@
   TitleChangeCallback title_change_callback_;
 
   media_router::PresentationNavigationPolicy navigation_policy_;
+
+  base::WeakPtrFactory<PresentationReceiverWindowController> weak_factory_{
+      this};
 };
 
 #endif  // CHROME_BROWSER_UI_MEDIA_ROUTER_PRESENTATION_RECEIVER_WINDOW_CONTROLLER_H_
diff --git a/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc b/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc
index dbdf12e..d9c5e9a9 100644
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc b/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
index efbe710..b208ee7 100644
--- a/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
+++ b/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
@@ -34,6 +34,7 @@
 using ::testing::_;
 using testing::Mock;
 using ::testing::NiceMock;
+using ::testing::SaveArg;
 using testing::WithArg;
 
 namespace media_router {
@@ -153,6 +154,26 @@
     base::RunLoop().RunUntilIdle();
   }
 
+  // POC helper: invokes the private OnSinkQueryUpdated()
+  // (CastMediaRouteProvider friends this fixture class) and returns the
+  // |origins| that were forwarded to MediaRouter::OnSinksReceived -- i.e. the
+  // result of GetOrigins().
+  std::vector<url::Origin> GetOnSinksReceivedOrigins(
+      const MediaSource::Id& source_id,
+      const std::vector<MediaSinkInternal>& sinks) {
+    std::vector<url::Origin> captured_origins;
+    base::RunLoop run_loop;
+    EXPECT_CALL(mock_router_, OnSinksReceived(mojom::MediaRouteProviderId::CAST,
+                                              source_id, sinks, _))
+        .WillOnce(
+            testing::DoAll(SaveArg<3>(&captured_origins),
+                           base::test::RunOnceClosure(run_loop.QuitClosure())));
+    provider_->OnSinkQueryUpdated(source_id, sinks);
+    run_loop.Run();
+    Mock::VerifyAndClearExpectations(&mock_router_);
+    return captured_origins;
+  }
+
   void UpdateSinkQueryAndExpectSinkReceived(
       const std::vector<MediaSinkInternal>& expected_received_sinks,
       const MediaSource::Id& source_id,
@@ -201,6 +222,43 @@
   EXPECT_TRUE(app_discovery_service_.callbacks().empty());
 }
 
+TEST_F(CastMediaRouteProviderTest, PresentationApiMirroringOriginAllowlist) {
+  struct Case {
+    const char* name;
+    const char* source_id;
+  } const cases[] = {
+      {"video", "cast:0F5096E8?clientId=1"},
+      {"audio-only", "cast:85CDB22F?clientId=1"},
+      {"legacy-url",
+       "https://google.com/cast#__castAppId__=0F5096E8/__castClientId__=1"},
+  };
+
+  const MediaSinkInternal sink = CreateCastSink(1);
+  const std::vector<MediaSinkInternal> sinks = {sink};
+
+  for (const auto& c : cases) {
+    // The IsCastPresentationUrl + ContainsStreamingApp combination is exactly
+    // what GetMirroringType() uses to select MirroringType::kTab.
+    EXPECT_TRUE(MediaSource(c.source_id).IsCastPresentationUrl());
+
+    // All three sources are routed as tab-mirroring by CastActivityManager
+    // (DoLaunchSession -> ContainsStreamingApp() -> AddMirroringActivity).
+    auto cast_source = CastMediaSource::FromMediaSourceId(c.source_id);
+    ASSERT_TRUE(cast_source);
+    EXPECT_TRUE(cast_source->ContainsStreamingApp())
+        << c.source_id << " is treated as a Cast Streaming (mirroring) app";
+
+    // OnSinkQueryUpdated computes GetOrigins(source_id) and forwards it to
+    // MediaRouter::OnSinksReceived.
+    std::vector<url::Origin> captured_origins =
+        GetOnSinksReceivedOrigins(c.source_id, sinks);
+
+    // kPresentationApiAllowlist is applied, restricting these sources to
+    // trusted origins.
+    EXPECT_EQ(captured_origins.size(), 3u);
+  }
+}
+
 TEST_F(CastMediaRouteProviderTest, CreateRouteFailsInvalidSink) {
   // Sink does not exist.
   provider_->CreateRoute(
diff --git a/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc b/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc
index dbdf12e..d9c5e9a9 100644
--- a/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc
+++ b/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc
@@ -11,6 +11,8 @@
 #include "base/functional/bind.h"
 #include "base/path_service.h"
 #include "base/run_loop.h"
+#include "base/strings/escape.h"
+#include "base/task/single_thread_task_runner.h"
 #include "base/test/run_until.h"
 #include "base/threading/thread_restrictions.h"
 #include "base/timer/elapsed_timer.h"
@@ -34,12 +36,15 @@
 #include "mojo/public/cpp/bindings/receiver.h"
 #include "mojo/public/cpp/bindings/remote.h"
 #include "net/base/filename_util.h"
+#include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/embedded_test_server.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "third_party/blink/public/mojom/presentation/presentation.mojom.h"
 #include "ui/display/display.h"
 #include "ui/display/screen.h"
 #include "ui/views/widget/widget.h"
 #include "url/gurl.h"
+#include "url/origin.h"
 
 using testing::_;
 
@@ -310,6 +315,127 @@
   destroyer.AwaitTerminate(std::move(receiver_window));
 }
 
+class PresentationReceiverNavigationBrowserTest
+    : public PresentationReceiverWindowControllerBrowserTest {
+ protected:
+  PresentationReceiverNavigationBrowserTest()
+      : https_server_(net::EmbeddedTestServer::TYPE_HTTPS) {}
+
+  void SetUpOnMainThread() override {
+    PresentationReceiverWindowControllerBrowserTest::SetUpOnMainThread();
+    host_resolver()->AddRule("*", "127.0.0.1");
+    // navigator.presentation is [SecureContext]; serve over HTTPS so the
+    // hijacker page's user JS can read the stolen connection.
+    https_server_.SetSSLConfig(net::EmbeddedTestServer::CERT_TEST_NAMES);
+    https_server_.ServeFilesFromSourceDirectory(
+        "chrome/test/data/media/router");
+    ASSERT_TRUE(https_server_.Start());
+  }
+
+  net::EmbeddedTestServer https_server_;
+};
+
+// Observes a receiver WebContents and records every committed primary
+// main-frame URL until the WebContents is destroyed.
+class CommittedUrlRecorder : public content::WebContentsObserver {
+ public:
+  explicit CommittedUrlRecorder(content::WebContents* wc)
+      : content::WebContentsObserver(wc) {}
+
+  void DidFinishNavigation(content::NavigationHandle* handle) override {
+    if (handle->IsInPrimaryMainFrame() && handle->HasCommitted()) {
+      committed_urls_.push_back(handle->GetURL());
+      LOG(ERROR) << "Main-frame navigation committed: "
+                 << handle->GetURL().spec();
+      if (on_commit_cb_ && handle->GetURL() == on_commit_url_) {
+        std::move(on_commit_cb_).Run();
+      }
+    }
+  }
+  void RunOnCommit(const GURL& url, base::OnceClosure cb) {
+    on_commit_url_ = url;
+    on_commit_cb_ = std::move(cb);
+  }
+  const std::vector<GURL>& committed_urls() const { return committed_urls_; }
+
+ private:
+  std::vector<GURL> committed_urls_;
+  GURL on_commit_url_;
+  base::OnceClosure on_commit_cb_;
+};
+
+IN_PROC_BROWSER_TEST_F(PresentationReceiverNavigationBrowserTest,
+                       CrossOriginNavigationDoesNotCommit) {
+  // Two distinct HTTPS origins (a.test vs b.test, both covered by
+  // CERT_TEST_NAMES) — site isolation puts them in different renderer
+  // processes and both are SecureContexts so navigator.presentation is exposed.
+  const GURL target_url = https_server_.GetURL("b.test", "/target.html");
+  const std::string receiver_path =
+      "/target_receiver.html?" +
+      base::EscapeQueryParamValue(target_url.spec(), /*use_plus=*/false);
+  const GURL start_url = https_server_.GetURL("a.test", receiver_path);
+  const url::Origin target_origin = url::Origin::Create(target_url);
+  ASSERT_NE(url::Origin::Create(start_url), target_origin);
+
+  // 1. Create the receiver window.
+  // Instead of ReceiverWindowDestroyer, we use a simple RunLoop to wait for
+  // the asynchronous termination callback.
+  base::RunLoop terminate_loop;
+  auto receiver_window =
+      PresentationReceiverWindowController::CreateFromOriginalProfile(
+          browser()->profile(), gfx::Rect(100, 100),
+          terminate_loop.QuitClosure(), GetNoopTitleChangeCallback());
+  CommittedUrlRecorder recorder(receiver_window->web_contents());
+  receiver_window->Start(kPresentationId, start_url);
+
+  // 2. start_url commits and Blink eagerly creates a PresentationReceiver.
+  //    start_url then attempts to navigate to target_url.
+  //    PresentationNavigationPolicy::AllowNavigation returns false for that
+  //    second main-frame navigation.
+  //    Our fix asynchronously stops the navigation and terminates the window,
+  //    which runs the termination callback and quits the loop.
+  terminate_loop.Run();
+
+  // 3. Verify that the disallowed navigation never committed.
+  EXPECT_EQ(1u, recorder.committed_urls().size());
+  EXPECT_EQ(start_url, recorder.committed_urls()[0]);
+
+  // 4. Register a controller connection for the same presentation_id.
+  //    Since the receiver window is destroyed/terminated, the connection
+  //    should not be hijacked or routed to target.
+  FakeControllerConnection controller_connection;
+  media_router::LocalPresentationManagerFactory::GetOrCreateForBrowserContext(
+      browser()->profile())
+      ->RegisterLocalPresentationController(
+          blink::mojom::PresentationInfo(start_url, kPresentationId),
+          content::GlobalRenderFrameHostId(0, 0), controller_connection.Bind(),
+          controller_connection.MakeConnectionRequest(),
+          media_router::MediaRoute("route",
+                                   media_router::MediaSource(start_url), "sink",
+                                   "desc", true));
+
+  std::string received;
+  base::RunLoop loop;
+  EXPECT_CALL(controller_connection, OnMessage(_))
+      .WillRepeatedly([&](blink::mojom::PresentationConnectionMessagePtr msg) {
+        if (msg->is_message()) {
+          received = msg->get_message();
+        }
+        loop.Quit();
+      });
+
+  // Run the loop for a short time to ensure no message is received.
+  base::SingleThreadTaskRunner::GetCurrentDefault()->PostDelayedTask(
+      FROM_HERE, loop.QuitClosure(), base::Milliseconds(500));
+  loop.Run();
+
+  // Safely destroy the receiver window controller.
+  receiver_window.reset();
+
+  // 5. Verify that no message to target was received.
+  EXPECT_TRUE(received.empty());
+}
+
 IN_PROC_BROWSER_TEST_F(PresentationReceiverWindowControllerBrowserTest,
                        WindowClosingTerminatesPresentation) {
   // Start receiver window.
diff --git a/chrome/test/data/media/router/target.html b/chrome/test/data/media/router/target.html
new file mode 100644
index 0000000..0319655
--- /dev/null
+++ b/chrome/test/data/media/router/target.html
@@ -0,0 +1,33 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Target page</title>
+<script>
+  // This page is cross-origin from the start_url. Because the receiver
+  // WebContents has web_prefs.presentation_receiver=true (set on the
+  // WebContents and preserved across navigation) and
+  // ReceiverPresentationServiceDelegateImpl is a WebContentsUserData keyed by
+  // the original presentation_id, the eagerly-created PresentationReceiver
+  // for THIS document calls SetReceiver on the browser, and
+  // LocalPresentation::RegisterReceiver overwrites receiver_callback_ to
+  // point at this origin's renderer. Any controller connection registered for
+  // the presentation is then delivered here.
+  if (location.search !== '?warmup' && self === top) {
+    const recv = navigator.presentation.receiver;
+    if (recv) {
+      recv.connectionList.then(list => {
+        const grab = c => {
+          c.onconnect = () => c.send('CAPTURED-BY:' + location.origin);
+          if (c.state === 'connected') {
+            c.send('CAPTURED-BY:' + location.origin);
+          }
+        };
+        list.connections.forEach(grab);
+        list.onconnectionavailable = e => grab(e.connection);
+      });
+    }
+  }
+</script>
+</head>
+<body>target</body>
+</html>
diff --git a/chrome/test/data/media/router/target_receiver.html b/chrome/test/data/media/router/target_receiver.html
new file mode 100644
index 0000000..2e113fd
--- /dev/null
+++ b/chrome/test/data/media/router/target_receiver.html
@@ -0,0 +1,42 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Presentation receiver that navigates cross-origin</title>
+</head>
+<body>receiver
+<script>
+  // Note: Blink's ModulesInitializer eagerly creates a PresentationReceiver
+  // for every outermost document with settings.presentation_receiver=true, so
+  // this page already holds the LocalPresentation's receiver_callback_. The
+  // hijack works because the cross-origin page that commits next ALSO gets an
+  // eagerly-created PresentationReceiver, and LocalPresentation::
+  // RegisterReceiver (with DCHECKs off, as in production) silently overwrites
+  // receiver_callback_ to point at that page's renderer.
+
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential bypass of Presentation API origin allowlist for site-initiated tab mirroring

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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic flaw in the Cast Media Router’s mirroring source detection allows unauthorized origins to bypass the Presentation API allowlist. By using alternative app IDs or legacy URL formats, any website can programmatically trigger the Cast device picker for tab mirroring.

Affected files:

  • components/media_router/common/providers/cast/cast_media_source.cc
  • chrome/browser/media/router/providers/cast/cast_media_route_provider.cc
  • components/media_router/browser/media_sinks_observer.cc
  • components/media_router/common/media_source.cc

Estimated timestamp from git blame: 2018-03-07

Description

The Chromium Cast Media Router implements an origin allowlist (kPresentationApiAllowlist) intended to restrict site-initiated tab mirroring via the Presentation API to a small set of trusted Google origins (e.g., Google Docs, Meet, and YouTube Music). This restriction is enforced in CastMediaRouteProvider::GetOrigins by checking if a MediaSource is a site-initiated mirroring source and then validating the requester’s origin against the allowlist.

However, the detection logic in IsSiteInitiatedMirroringSource is incomplete, allowing arbitrary origins to bypass this security control by crafting specific Presentation URLs that the identification logic fails to recognize as mirroring sources, yet the subsequent session initiation logic treats as mirroring requests.

Root Cause

The function IsSiteInitiatedMirroringSource in components/media_router/common/providers/cast/cast_media_source.cc is used to determine if a request should be subject to allowlist enforcement. It currently only identifies sources that use the cast: scheme followed by the specific audio+video streaming app ID (0F5096E8):

bool IsSiteInitiatedMirroringSource(const MediaSource::Id& source_id) {
  return base::StartsWith(
      source_id,
      base::StrCat(
          {"cast:", openscreen::cast::GetCastStreamingAudioVideoAppId()}),
      base::CompareCase::SENSITIVE);
}

There are two potential ways to bypass this identification logic:

  1. Alternative App IDs: An attacker can use the audio-only streaming app ID (85CDB22F) in a cast: URL (e.g., cast:85CDB22F?clientId=...). Since IsSiteInitiatedMirroringSource only checks for the audio+video ID, it returns false. Consequently, CastMediaRouteProvider::GetOrigins returns an empty allowed_origins vector. In components/media_router/browser/media_sinks_observer.cc, an empty origin list is interpreted as a wildcard, allowing all origins to proceed. Despite the audio-only app ID, the resulting session initiated via MirroringActivity will perform tab capture and mirroring.

  2. Legacy Cast URLs: Legacy Cast URLs (e.g., https://google.com/cast#__castAppId__=0F5096E8/...) use the https scheme and thus bypass the prefix-based check in IsSiteInitiatedMirroringSource. These URLs are still recognized as valid Cast Presentation URLs and mirroring sources later in the session initiation process (e.g., in CastActivityManager and MirroringActivity), bypassing the allowlist entirely.

Impact

Any web origin can programmatically initiate site-initiated tab mirroring to a local Cast device. This allows the capturing of the entire current tab’s content, including cross-origin iframes. While the attack requires one user interaction to select a device from the trusted Cast picker UI, it represents a bypass of a deliberate security control designed to prevent unauthorized sites from triggering tab capture sessions.

Potential Reproduction Steps

  1. From an HTTPS origin not on the allowlist (e.g., https://example.com), execute the following JavaScript:
    // Potential bypass using audio-only app ID
    const request = new PresentationRequest('cast:85CDB22F?clientId=123');
    request.start().catch(err => console.log('Picker closed or error:', err));
    
    OR
    // Potential bypass using legacy URL format
    const request = new PresentationRequest('https://google.com/cast#__castAppId__=0F5096E8/__castClientId__=123');
    request.start().catch(err => console.log('Picker closed or error:', err));
    
  2. Observe that the Cast device picker appears and lists available sinks, which would normally be restricted for these types of mirroring requests on unauthorized origins.
  3. Upon user selection of a device, tab mirroring begins.

Suggested Fix

Update IsSiteInitiatedMirroringSource in components/media_router/common/providers/cast/cast_media_source.cc to correctly identify all forms of site-initiated mirroring sources. This should include all streaming app IDs returned by openscreen::cast::GetCastStreamingAppIds() and the legacy Cast URL prefix kLegacyCastPresentationUrlPrefix defined in components/media_router/common/media_source.h.

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


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