Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in PresentationAPI
DescriptionInappropriate implementation in PresentationAPI
ComponentPresentationAPI
Bug ClassLogic Error
Tracker513363822
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

Information Leak in PresentationConnection::send for ArrayBufferView

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: The PresentationConnection::send implementation for ArrayBufferView incorrectly transmits the entire backing ArrayBuffer instead of the specified view slice. This allows a receiver to access data in the controller’s memory that was not intended for transmission, potentially across different origins.

Affected files:

  • third_party/blink/renderer/modules/presentation/presentation_connection.cc
  • third_party/blink/renderer/core/typed_arrays/dom_array_buffer_view.h
  • third_party/blink/renderer/core/typed_arrays/dom_array_buffer_base.h

Estimated timestamp from git blame: 2015-06-08

Background

The Presentation API allows a ‘controller’ page to establish a connection and send messages to a ‘receiver’ page. The send() method can accept an ArrayBufferView (e.g., a Uint8Array). Standard behavior for web APIs (like WebSockets or WebRTC) is to transmit only the data within the view’s specified slice (defined by its byteOffset and byteLength).

The Potential Vulnerability

In third_party/blink/renderer/modules/presentation/presentation_connection.cc, the send overload for ArrayBufferView correctly validates the view’s length but then enqueues the entire backing buffer for transmission:

void PresentationConnection::send(
    NotShared<DOMArrayBufferView> array_buffer_view,
    ExceptionState& exception_state) {
  // ... (Checks for valid connection state) ...
  if (!base::CheckedNumeric<wtf_size_t>(array_buffer_view->byteLength())
           .IsValid()) {
    // ... (Throws RangeError) ...
    return;
  }

  // POTENTIAL BUG: Enqueues the full backing buffer instead of a slice
  messages_.push_back(
      MakeGarbageCollected<Message>(array_buffer_view->buffer()));
  HandleMessageQueue();
}

The message is later processed by MakeBinaryMessage, which extracts the full buffer span:

mojom::blink::PresentationConnectionMessagePtr MakeBinaryMessage(
    const DOMArrayBuffer* buffer) {
  auto message = mojom::blink::PresentationConnectionMessage::NewData(Vector<uint8_t>());
  Vector<uint8_t>& data = message->get_data();
  data.append_range(buffer->ByteSpan()); // Uses the full buffer span
  return message;
}

Because the view’s byteOffset() and byteLength() are ignored, any data residing in the same ArrayBuffer outside of the intended slice is leaked to the receiver. This is particularly sensitive if the buffer is part of a pooled allocator or a WebAssembly linear memory heap.

Size-Gate Bypass

A secondary effect of this issue is a bypass of the wtf_size_t size check. The code validates the byteLength() of the view (which might be small), but sends the backing buffer() (which may be significantly larger). On 64-bit systems, if the buffer size exceeds 4GiB, the size is truncated to 32 bits when appended to the message vector, but unintended data from the start of the buffer is still transmitted.

Suggested Potential Reproduction Steps

  1. Establish a PresentationConnection between a controller (Origin A) and a receiver (Origin B).
  2. On Origin A, allocate an ArrayBuffer and place sensitive data at the beginning.
  3. Create a Uint8Array view starting at an offset after the sensitive data.
  4. Call connection.send(view).
  5. Observe on Origin B that the received message contains the entire ArrayBuffer from Origin A, including the sensitive data at the start.

Suggested Fix

The PresentationConnection::send implementation should be modified to capture the specific slice defined by the ArrayBufferView. One approach is to create a new DOMArrayBuffer from the view’s slice before enqueuing it, or to update the Message class and MakeBinaryMessage to respect the view’s offset and length.

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