Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Presentation
DescriptionInsufficient policy enforcement in Presentation
ComponentPresentation
Bug ClassLogic Error
Tracker501980797
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 <[email protected]>
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 <[email protected]>
Reviewed-by: Muyao Xu <[email protected]>
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 [email protected]

Potential cross-origin PresentationConnection hijack in receiver window

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.

Overview: A logic error in Presentation API receiver windows allows a malicious receiver page to bypass navigation restrictions and hijack a pending PresentationConnection. The policy enforcement fails to cancel disallowed navigations, and the LocalPresentationManager flushes pending connections to the new origin without validating it against the requested URL.

Affected files:

  • chrome/browser/ui/media_router/presentation_receiver_window_controller.cc
  • components/media_router/browser/presentation/local_presentation_manager.cc
  • components/media_router/browser/presentation/receiver_presentation_service_delegate_impl.cc
  • components/media_router/browser/presentation/presentation_navigation_policy.cc

Estimated timestamp from git blame: 2024-12-23

Summary

A potential vulnerability exists in the wired-display Presentation API receiver implementation that allows a malicious receiver page to bypass navigation restrictions and hijack a pending PresentationConnection intended for the original origin.

This occurs because the PresentationReceiverWindowController does not successfully cancel disallowed cross-origin navigations, and the LocalPresentationManager flushes pending controller connections to whatever origin eventually commits in the receiver window.

Technical Details

1. Non-cancelling Navigation Policy

PresentationReceiverWindowController implements a PresentationNavigationPolicy intended to lock the receiver window to a single navigation (the start_url). When a second navigation is detected in DidStartNavigation, the policy correctly returns false. However, the controller’s response is to call Terminate(), which invokes web_contents_->ClosePage().

ClosePage() is an asynchronous operation that sends an IPC to the renderer to run unload handlers and starts a 500ms timeout (kUnloadTimeout). Crucially, DidStartNavigation does not cancel the current NavigationHandle (e.g., via web_contents_->Stop()). Consequently, the disallowed navigation proceeds to fetch and commit while the page closure is pending.

2. Connection Hijacking Logic

The hijacking occurs through the following sequence:

  1. A controller initiates a presentation. WiredDisplayMediaRouteProvider starts the receiver window and asynchronously fires a success callback to the controller.
  2. Because the receiver window has not finished loading the initial start_url, the controller’s PresentationConnection pipes are stored in LocalPresentationManager::pending_controllers_, keyed by a unique presentation_id.
  3. A malicious start_url (attacker.test) loads but does not access navigator.presentation (which avoids creating a PresentationServiceImpl). It then immediately triggers a client-side navigation to evil.test.
  4. DidStartNavigation fires, Terminate() is called, but the navigation to evil.test continues and commits in a new renderer process. The malicious attacker.test renderer can intentionally delay acknowledging the unload IPC to maximize the 500ms timeout.
  5. The web_prefs.presentation_receiver preference and the presentation_id (stored in ReceiverPresentationServiceDelegateImpl) are tied to the WebContents and are inherited by the new origin (evil.test).
  6. evil.test accesses navigator.presentation.receiver. This creates a new PresentationServiceImpl and triggers RegisterReceiverConnectionAvailableCallback.
  7. LocalPresentationManager::RegisterReceiver is called with the surviving presentation_id. It finds the pending_controllers_ and flushes the Mojo pipes for the connection to the evil.test renderer. The implementation entirely ignores the fact that the calling origin (evil.test) does not match the URL originally requested by the controller (attacker.test).

At this point, evil.test has full access to the PresentationConnection that was intended for the original start_url origin. The attacker has a window of approximately 500ms to exfiltrate data or send malicious messages before the window is forcefully closed.

Suggested Steps to Reproduce

Note: These are potential steps based on static analysis, as our tooling does not currently run code.

  1. From a controller page, start a presentation to a wired display using a URL controlled by the attacker (https://attacker.test/receiver.html).
  2. receiver.html should immediately navigate to a different origin: location.href='https://evil.test/hijack.html'. It must not access navigator.presentation before navigating.
  3. attacker.test should artificially delay its unload handler to ensure the 500ms timeout is utilized.
  4. hijack.html accesses navigator.presentation.receiver.connectionList and observes the incoming connection.
  5. Observe that hijack.html (on evil.test) can now send and receive messages from the controller, even though the connection was associated with attacker.test.

Suggested Fix

  1. Synchronous Abortion: PresentationReceiverWindowController::DidStartNavigation should explicitly abort the navigation before calling Terminate(). Calling web_contents_->Stop() or discarding pending navigation entries would prevent the disallowed page from ever committing.
  2. Origin Validation: LocalPresentationManager::OnLocalPresentationReceiverCreated should verify that the presentation_info URL matches the URL that was originally requested by the controller, rather than relying solely on the presentation_id.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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