diff --git a/content/browser/BUILD.gn b/content/browser/BUILD.gn index 7b194ea..f73edef 100644 --- a/content/browser/BUILD.gn +++ b/content/browser/BUILD.gn @@ -2521,8 +2521,17 @@ source_set("browser") { deps += [ "//media/mojo/mojom" ] } + if (enable_mdns) { + sources += [ + "renderer_host/no_op_mdns_responder.cc", + "renderer_host/no_op_mdns_responder.h", + ] + } + if (is_p2p_enabled) { sources += [ + "renderer_host/p2p/no_op_p2p_socket_manager.cc", + "renderer_host/p2p/no_op_p2p_socket_manager.h", "renderer_host/p2p/socket_dispatcher_host.cc", "renderer_host/p2p/socket_dispatcher_host.h", ] diff --git a/content/browser/browser_interface_binders.cc b/content/browser/browser_interface_binders.cc index 94c73d9..26ca7ce 100644 --- a/content/browser/browser_interface_binders.cc +++ b/content/browser/browser_interface_binders.cc @@ -27,6 +27,7 @@ #include "content/browser/bluetooth/web_bluetooth_service_impl.h" #include "content/browser/browser_context_impl.h" #include "content/browser/browser_main_loop.h" +#include "content/browser/connection_allowlist_utils.h" #include "content/browser/contacts/contacts_manager_impl.h" #include "content/browser/content_index/content_index_service_impl.h" #include "content/browser/cookie_store/cookie_store_manager.h" @@ -119,6 +120,7 @@ #include "services/device/public/mojom/vibration_manager.mojom.h" #include "services/metrics/public/mojom/ukm_interface.mojom.h" #include "services/metrics/ukm_recorder_factory_impl.h" +#include "services/network/public/cpp/connection_allowlist.h" #include "services/network/public/cpp/features.h" #include "services/network/public/cpp/is_potentially_trustworthy.h" #include "services/network/public/cpp/network_service_buildflags.h" @@ -225,6 +227,7 @@ #endif // BUILDFLAG(IS_ANDROID) #if BUILDFLAG(IS_P2P_ENABLED) +#include "content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h" #include "services/network/public/mojom/p2p.mojom.h" #endif // BUILDFLAG(IS_P2P_ENABLED) @@ -790,6 +793,17 @@ void BindMediaPlayerObserverClientHandler( void BindSocketManager( RenderFrameHost* frame, mojo::PendingReceiver receiver) { + // If WebRTC is blocked by the document's Connection Allowlist, bind a no-op + // P2PSocketManager so that no sockets are ever created, while still letting + // the renderer fail gracefully (see NoOpP2PSocketManager). It is possible + // that a renderer which does not allow WebRTC could still reach this + // interface. TODO(crbug.com/571239543): Disable all WebRTC connections on the + // renderer side. + if (!IsWebRTCAllowedByConnectionAllowlist(frame)) { + NoOpP2PSocketManager::Create(std::move(receiver)); + return; + } + static_cast(frame->GetProcess()) ->BindP2PSocketManager( frame->GetIsolationInfoForSubresources().network_anonymization_key(), @@ -991,26 +1005,9 @@ void PopulateBinderMapWithContext( map->Add( &BindRenderFrameHostImpl<&RenderFrameHostImpl::BindIdleManager>); -#if BUILDFLAG(IS_P2P_ENABLED) || BUILDFLAG(ENABLE_MDNS) - // TODO(447954811): Remove the fenced frames check if we end up supporting - // Connection Allowlist for fenced frames. - bool should_ban_p2p_for_connection_allowlist = - host->HasPolicyContainerHost() && - host->policy_container_host() - ->connection_allowlists() - .enforced.has_value() && - host->policy_container_host() - ->connection_allowlists() - .enforced->webrtc_behavior == - network::ConnectionAllowlist::WebRtcBehavior::kBlock && - !host->IsNestedWithinFencedFrame(); -# endif // BUILDFLAG(IS_P2P_ENABLED) || BUILDFLAG(ENABLE_MDNS) - #if BUILDFLAG(ENABLE_MDNS) - if (!should_ban_p2p_for_connection_allowlist) { - map->Add( + map->Add( &BindRenderFrameHostImpl<&RenderFrameHostImpl::CreateMdnsResponder>); - } #endif // BUILDFLAG(ENABLE_MDNS) // BrowserMainLoop::GetInstance() may be null on unit tests. @@ -1027,18 +1024,7 @@ void PopulateBinderMapWithContext( &RenderFrameHostImpl::CreateNotificationService>); #if BUILDFLAG(IS_P2P_ENABLED) - // WebRTC p2p connections are disallowed in fenced frames. Creation of - // RTCPeerConnection is already disabled in the renderer, so in theory this - // unbound interface should never present an issue. - bool should_ban_p2p_for_fenced_frames = - base::FeatureList::IsEnabled( - blink::features::kFencedFramesLocalUnpartitionedDataAccess) && - host->IsNestedWithinFencedFrame(); - - if (!should_ban_p2p_for_fenced_frames && - !should_ban_p2p_for_connection_allowlist) { - map->Add(&BindSocketManager); - } + map->Add(&BindSocketManager); #endif // BUILDFLAG(IS_P2P_ENABLED) map->Add( diff --git a/content/browser/connection_allowlist_utils.cc b/content/browser/connection_allowlist_utils.cc index 379f029..b366763 100644 --- a/content/browser/connection_allowlist_utils.cc +++ b/content/browser/connection_allowlist_utils.cc @@ -141,4 +141,22 @@ bool FrameConnectionAllowlistAllowsRequestAndReportIfNeeded( return ConnectionAllowlistAllowsUrlAndReportIfNeeded(policies, url); } +bool IsWebRTCAllowedByConnectionAllowlist(RenderFrameHost* frame) { + // A speculative RFH doesn't have a PolicyContainerHost, and in theory it + // should not be binding WebRTC interfaces while speculative. So fail closed. + if (!frame->HasPolicyContainerHost()) { + return false; + } + + const std::optional& enforced_ca = + frame->GetConnectionAllowlists().enforced; + if (enforced_ca.has_value() && + enforced_ca->webrtc_behavior == + network::ConnectionAllowlist::WebRtcBehavior::kBlock) { + return false; + } + + return true; +} + } // namespace content diff --git a/content/browser/connection_allowlist_utils.h b/content/browser/connection_allowlist_utils.h index ee65754..ab5ecae 100644 --- a/content/browser/connection_allowlist_utils.h +++ b/content/browser/connection_allowlist_utils.h @@ -15,6 +15,7 @@ class HttpResponseHeaders; namespace content { +class RenderFrameHost; struct PolicyContainerPolicies; // Returns true if the parsed response headers contains a valid @@ -47,6 +48,12 @@ network::ConnectionAllowlists GetConnectionAllowlistsForWorker( const PolicyContainerPolicies* creator_policies, bool inherit_from_creator); +// Determines if the Connection Allowlist for `frame` has allowed WebRTC. This +// is controlled by the Connection Allowlist header's `webrtc` directive, and is +// is used to prevent binding of downstream interfaces like P2PSocketManger and +// MDNSResponder. +bool IsWebRTCAllowedByConnectionAllowlist(RenderFrameHost* frame); + } // namespace content #endif // CONTENT_BROWSER_CONNECTION_ALLOWLIST_UTILS_H_ diff --git a/content/browser/renderer_host/no_op_mdns_responder.cc b/content/browser/renderer_host/no_op_mdns_responder.cc new file mode 100644 index 0000000..d2ff53d --- /dev/null +++ b/content/browser/renderer_host/no_op_mdns_responder.cc @@ -0,0 +1,38 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "content/browser/renderer_host/no_op_mdns_responder.h" + +#include +#include +#include + +#include "mojo/public/cpp/bindings/self_owned_receiver.h" + +namespace content { + +// static +void NoOpMdnsResponder::Create( + mojo::PendingReceiver receiver) { + mojo::MakeSelfOwnedReceiver(std::make_unique(), + std::move(receiver)); +} + +NoOpMdnsResponder::NoOpMdnsResponder() = default; +NoOpMdnsResponder::~NoOpMdnsResponder() = default; + +void NoOpMdnsResponder::CreateNameForAddress( + const net::IPAddress& address, + CreateNameForAddressCallback callback) { + std::move(callback).Run(/*name=*/std::string(), + /*announcement_scheduled=*/false); +} + +void NoOpMdnsResponder::RemoveNameForAddress( + const net::IPAddress& address, + RemoveNameForAddressCallback callback) { + std::move(callback).Run(/*removed=*/false, /*goodbye_scheduled=*/false); +} + +} // namespace content diff --git a/content/browser/renderer_host/no_op_mdns_responder.h b/content/browser/renderer_host/no_op_mdns_responder.h new file mode 100644 index 0000000..3f6556b --- /dev/null +++ b/content/browser/renderer_host/no_op_mdns_responder.h @@ -0,0 +1,39 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CONTENT_BROWSER_RENDERER_HOST_NO_OP_MDNS_RESPONDER_H_ +#define CONTENT_BROWSER_RENDERER_HOST_NO_OP_MDNS_RESPONDER_H_ + +#include "content/common/content_export.h" +#include "mojo/public/cpp/bindings/pending_receiver.h" +#include "services/network/public/mojom/mdns_responder.mojom.h" + +namespace content { + +// An MdnsResponder that never registers or announces any names. It is bound in +// place of the real (network service backed) MdnsResponder for documents whose +// Connection Allowlist blocks WebRTC. All reply callbacks are run with failure +// values so that no renderer request hangs. +class CONTENT_EXPORT NoOpMdnsResponder : public network::mojom::MdnsResponder { + public: + // Creates a self-owned NoOpMdnsResponder bound to `receiver`. It is destroyed + // when the renderer closes the pipe. + static void Create( + mojo::PendingReceiver receiver); + + NoOpMdnsResponder(); + NoOpMdnsResponder(const NoOpMdnsResponder&) = delete; + NoOpMdnsResponder& operator=(const NoOpMdnsResponder&) = delete; + ~NoOpMdnsResponder() override; + + // network::mojom::MdnsResponder: + void CreateNameForAddress(const net::IPAddress& address, + CreateNameForAddressCallback callback) override; + void RemoveNameForAddress(const net::IPAddress& address, + RemoveNameForAddressCallback callback) override; +}; + +} // namespace content + +#endif // CONTENT_BROWSER_RENDERER_HOST_NO_OP_MDNS_RESPONDER_H_ diff --git a/content/browser/renderer_host/no_op_mdns_responder_unittest.cc b/content/browser/renderer_host/no_op_mdns_responder_unittest.cc new file mode 100644 index 0000000..0a256ad --- /dev/null +++ b/content/browser/renderer_host/no_op_mdns_responder_unittest.cc @@ -0,0 +1,87 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "content/browser/renderer_host/no_op_mdns_responder.h" + +#include + +#include "base/test/task_environment.h" +#include "base/test/test_future.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "net/base/ip_address.h" +#include "services/network/public/mojom/mdns_responder.mojom.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace content { + +namespace { + +class NoOpMdnsResponderTest : public testing::Test { + public: + NoOpMdnsResponderTest() { + NoOpMdnsResponder::Create(responder_.BindNewPipeAndPassReceiver()); + } + + protected: + base::test::TaskEnvironment task_environment_; + mojo::Remote responder_; +}; + +// CreateNameForAddress() must always run its callback, without creating a name +// or scheduling an announcement. +TEST_F(NoOpMdnsResponderTest, CreateNameForAddressFails) { + base::test::TestFuture future; + responder_->CreateNameForAddress(net::IPAddress(192, 168, 0, 1), + future.GetCallback()); + ASSERT_TRUE(future.Wait()); + EXPECT_TRUE(future.Get<0>().empty()); + EXPECT_FALSE(future.Get<1>()); + EXPECT_TRUE(responder_.is_connected()); +} + +// Repeated requests for the same address also fail; no name is ever retained. +TEST_F(NoOpMdnsResponderTest, CreateNameForAddressRepeatedlyFails) { + const net::IPAddress address(10, 0, 0, 1); + for (int i = 0; i < 3; ++i) { + base::test::TestFuture future; + responder_->CreateNameForAddress(address, future.GetCallback()); + ASSERT_TRUE(future.Wait()); + EXPECT_TRUE(future.Get<0>().empty()); + EXPECT_FALSE(future.Get<1>()); + } + EXPECT_TRUE(responder_.is_connected()); +} + +// RemoveNameForAddress() must always run its callback, reporting that nothing +// was removed and no goodbye was scheduled. +TEST_F(NoOpMdnsResponderTest, RemoveNameForAddressFails) { + base::test::TestFuture future; + responder_->RemoveNameForAddress(net::IPAddress(192, 168, 0, 1), + future.GetCallback()); + ASSERT_TRUE(future.Wait()); + EXPECT_FALSE(future.Get<0>()); + EXPECT_FALSE(future.Get<1>()); + EXPECT_TRUE(responder_.is_connected()); +} + +// Works for IPv6 addresses too. +TEST_F(NoOpMdnsResponderTest, Ipv6AddressFails) { + base::test::TestFuture create_future; + responder_->CreateNameForAddress(net::IPAddress::IPv6Localhost(), + create_future.GetCallback()); + ASSERT_TRUE(create_future.Wait()); + EXPECT_TRUE(create_future.Get<0>().empty()); + EXPECT_FALSE(create_future.Get<1>()); + + base::test::TestFuture remove_future; + responder_->RemoveNameForAddress(net::IPAddress::IPv6Localhost(), + remove_future.GetCallback()); + ASSERT_TRUE(remove_future.Wait()); + EXPECT_FALSE(remove_future.Get<0>()); + EXPECT_FALSE(remove_future.Get<1>()); +} + +} // namespace + +} // namespace content diff --git a/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.cc b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.cc new file mode 100644 index 0000000..b00ee09 --- /dev/null +++ b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.cc @@ -0,0 +1,57 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h" + +#include +#include + +#include "mojo/public/cpp/bindings/self_owned_receiver.h" +#include "net/base/ip_address.h" + +namespace content { + +// static +void NoOpP2PSocketManager::Create( + mojo::PendingReceiver receiver) { + mojo::MakeSelfOwnedReceiver(std::make_unique(), + std::move(receiver)); +} + +NoOpP2PSocketManager::NoOpP2PSocketManager() = default; +NoOpP2PSocketManager::~NoOpP2PSocketManager() = default; + +void NoOpP2PSocketManager::StartNetworkNotifications( + mojo::PendingRemote client) { + network_notification_client_.reset(); + network_notification_client_.Bind(std::move(client)); + // Report an empty network list so nothing is waiting on network changes. + network_notification_client_->NetworkListChanged( + /*networks=*/{}, /*default_ipv4_local_address=*/net::IPAddress(), + /*default_ipv6_local_address=*/net::IPAddress()); +} + +void NoOpP2PSocketManager::GetHostAddress( + const std::string& host_name, + std::optional address_family, + bool enable_mdns, + GetHostAddressCallback callback) { + // Never resolve anything. + std::move(callback).Run(/*addresses=*/{}); +} + +void NoOpP2PSocketManager::CreateSocket( + network::P2PSocketType type, + const net::IPEndPoint& local_address, + const network::P2PPortRange& port_range, + const network::P2PHostAndIPEndPoint& remote_address, + const net::MutableNetworkTrafficAnnotationTag& traffic_annotation, + const std::optional& devtools_token, + mojo::PendingRemote client, + mojo::PendingReceiver receiver) { + // Never create a socket. Letting these pending pipes go out of scope should + // destroy them and treat them as disconnected. +} + +} // namespace content diff --git a/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h new file mode 100644 index 0000000..cf05b8f --- /dev/null +++ b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h @@ -0,0 +1,69 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CONTENT_BROWSER_RENDERER_HOST_P2P_NO_OP_P2P_SOCKET_MANAGER_H_ +#define CONTENT_BROWSER_RENDERER_HOST_P2P_NO_OP_P2P_SOCKET_MANAGER_H_ + +#include +#include + +#include "content/common/content_export.h" +#include "mojo/public/cpp/bindings/pending_receiver.h" +#include "mojo/public/cpp/bindings/pending_remote.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "services/network/public/mojom/p2p.mojom.h" + +namespace content { + +// A P2PSocketManager that never performs any networking. It is bound in place +// of the real (network service backed) P2PSocketManager for documents whose +// Connection Allowlist blocks WebRTC. +// +// Binding a no-op implementation (rather than dropping the receiver or leaving +// the interface unregistered) lets an unmodified renderer fail gracefully: +// - The pipe stays connected, so the renderer's P2PSocketDispatcher does not +// enter its reconnect loop. +// - An empty network list is reported, so ICE gathering is allowed to proceed. +// - All reply callbacks are run, so no request hangs. +class CONTENT_EXPORT NoOpP2PSocketManager + : public network::mojom::P2PSocketManager { + public: + // Creates a self-owned NoOpP2PSocketManager bound to `receiver`. It is + // destroyed when the renderer closes the pipe. + static void Create( + mojo::PendingReceiver receiver); + + NoOpP2PSocketManager(); + NoOpP2PSocketManager(const NoOpP2PSocketManager&) = delete; + NoOpP2PSocketManager& operator=(const NoOpP2PSocketManager&) = delete; + ~NoOpP2PSocketManager() override; + + // network::mojom::P2PSocketManager: + void StartNetworkNotifications( + mojo::PendingRemote client) + override; + void GetHostAddress(const std::string& host_name, + std::optional address_family, + bool enable_mdns, + GetHostAddressCallback callback) override; + void CreateSocket( + network::P2PSocketType type, + const net::IPEndPoint& local_address, + const network::P2PPortRange& port_range, + const network::P2PHostAndIPEndPoint& remote_address, + const net::MutableNetworkTrafficAnnotationTag& traffic_annotation, + const std::optional& devtools_token, + mojo::PendingRemote client, + mojo::PendingReceiver receiver) override; + + private: + // Kept alive for the lifetime of this object so that the renderer does not + // observe a disconnect on its notification client. + mojo::Remote + network_notification_client_; +}; + +} // namespace content + +#endif // CONTENT_BROWSER_RENDERER_HOST_P2P_NO_OP_P2P_SOCKET_MANAGER_H_ diff --git a/content/browser/renderer_host/p2p/no_op_p2p_socket_manager_unittest.cc b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager_unittest.cc new file mode 100644 index 0000000..c83b26e --- /dev/null +++ b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager_unittest.cc @@ -0,0 +1,171 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h" + +#include +#include + +#include "base/functional/bind.h" +#include "base/run_loop.h" +#include "base/test/task_environment.h" +#include "base/test/test_future.h" +#include "mojo/public/cpp/bindings/pending_remote.h" +#include "mojo/public/cpp/bindings/receiver.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "net/base/ip_address.h" +#include "net/base/ip_endpoint.h" +#include "net/base/network_interfaces.h" +#include "net/traffic_annotation/network_traffic_annotation_test_helper.h" +#include "services/network/public/cpp/p2p_socket_type.h" +#include "services/network/public/mojom/p2p.mojom.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace content { + +namespace { + +class FakeNetworkNotificationClient + : public network::mojom::P2PNetworkNotificationClient { + public: + FakeNetworkNotificationClient() = default; + FakeNetworkNotificationClient(const FakeNetworkNotificationClient&) = delete; + FakeNetworkNotificationClient& operator=( + const FakeNetworkNotificationClient&) = delete; + ~FakeNetworkNotificationClient() override = default; + + mojo::PendingRemote + BindNewPipeAndPassRemote() { + auto remote = receiver_.BindNewPipeAndPassRemote(); + receiver_.set_disconnect_handler(base::BindOnce( + [](bool* disconnected) { *disconnected = true; }, &disconnected_)); + return remote; + } + + bool disconnected() const { return disconnected_; } + + base::test::TestFuture, + net::IPAddress, + net::IPAddress>& + network_list_future() { + return network_list_future_; + } + + // network::mojom::P2PNetworkNotificationClient: + void NetworkListChanged( + const std::vector& networks, + const net::IPAddress& default_ipv4_local_address, + const net::IPAddress& default_ipv6_local_address) override { + network_list_future_.SetValue(networks, default_ipv4_local_address, + default_ipv6_local_address); + } + + private: + mojo::Receiver receiver_{this}; + base::test::TestFuture, + net::IPAddress, + net::IPAddress> + network_list_future_; + bool disconnected_ = false; +}; + +class NoOpP2PSocketManagerTest : public testing::Test { + public: + NoOpP2PSocketManagerTest() { + NoOpP2PSocketManager::Create(manager_.BindNewPipeAndPassReceiver()); + } + + protected: + base::test::TaskEnvironment task_environment_; + mojo::Remote manager_; +}; + +// StartNetworkNotifications() must report an (empty) network list so that the +// renderer does not wait forever for one, and must keep the client connected. +TEST_F(NoOpP2PSocketManagerTest, StartNetworkNotificationsReportsEmptyList) { + FakeNetworkNotificationClient client; + manager_->StartNetworkNotifications(client.BindNewPipeAndPassRemote()); + + auto& future = client.network_list_future(); + ASSERT_TRUE(future.Wait()); + EXPECT_TRUE(future.Get<0>().empty()); + EXPECT_TRUE(future.Get<1>().empty()); + EXPECT_TRUE(future.Get<2>().empty()); + + // The notification client must remain connected; otherwise the renderer + // would observe a disconnect. + manager_.FlushForTesting(); + EXPECT_FALSE(client.disconnected()); + EXPECT_TRUE(manager_.is_connected()); +} + +// A second StartNetworkNotifications() call replaces the previous client and +// also receives an empty network list. +TEST_F(NoOpP2PSocketManagerTest, StartNetworkNotificationsTwice) { + FakeNetworkNotificationClient first_client; + manager_->StartNetworkNotifications(first_client.BindNewPipeAndPassRemote()); + ASSERT_TRUE(first_client.network_list_future().Wait()); + + FakeNetworkNotificationClient second_client; + manager_->StartNetworkNotifications(second_client.BindNewPipeAndPassRemote()); + auto& future = second_client.network_list_future(); + ASSERT_TRUE(future.Wait()); + EXPECT_TRUE(future.Get<0>().empty()); + + manager_.FlushForTesting(); + EXPECT_TRUE(first_client.disconnected()); + EXPECT_FALSE(second_client.disconnected()); + EXPECT_TRUE(manager_.is_connected()); +} + +// GetHostAddress() must always run its callback, with no addresses. +TEST_F(NoOpP2PSocketManagerTest, GetHostAddressReturnsNoAddresses) { + base::test::TestFuture&> future; + manager_->GetHostAddress("example.com", /*address_family=*/std::nullopt, + /*enable_mdns=*/true, future.GetCallback()); + EXPECT_TRUE(future.Get().empty()); + + // mDNS-style names must not be resolved either. + base::test::TestFuture&> mdns_future; + manager_->GetHostAddress("00000000-0000-0000-0000-000000000000.local", + /*address_family=*/std::nullopt, + /*enable_mdns=*/true, mdns_future.GetCallback()); + EXPECT_TRUE(mdns_future.Get().empty()); + + EXPECT_TRUE(manager_.is_connected()); +} + +// CreateSocket() must never create a socket. The socket pipe is closed, so the +// renderer treats the socket as failed, while the manager stays connected (so +// the renderer does not attempt to reconnect). +TEST_F(NoOpP2PSocketManagerTest, CreateSocketClosesSocketPipe) { + mojo::PendingReceiver client_receiver; + mojo::PendingRemote client = + client_receiver.InitWithNewPipeAndPassRemote(); + + mojo::Remote socket; + base::test::TestFuture socket_disconnected; + auto socket_receiver = socket.BindNewPipeAndPassReceiver(); + socket.set_disconnect_handler(socket_disconnected.GetCallback()); + + manager_->CreateSocket( + network::P2P_SOCKET_UDP, + net::IPEndPoint(net::IPAddress::IPv4Localhost(), 0), + network::P2PPortRange(), + network::P2PHostAndIPEndPoint( + "example.com", net::IPEndPoint(net::IPAddress::IPv4Localhost(), 0)), + net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS), + /*devtools_token=*/std::nullopt, std::move(client), + std::move(socket_receiver)); + + EXPECT_TRUE(socket_disconnected.Wait()); + EXPECT_FALSE(socket.is_connected()); + + manager_.FlushForTesting(); + EXPECT_TRUE(manager_.is_connected()); +} + +} // namespace + +} // namespace content diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc index a08ebce..2829cbb 100644 --- a/content/browser/renderer_host/render_frame_host_impl.cc +++ b/content/browser/renderer_host/render_frame_host_impl.cc @@ -88,6 +88,10 @@ #include "content/browser/can_commit_status.h" #include "content/browser/closewatcher/close_listener_host.h" #include "content/browser/code_cache/generated_code_cache_context.h" +#include "content/browser/connection_allowlist_utils.h" +#if BUILDFLAG(ENABLE_MDNS) +#include "content/browser/renderer_host/no_op_mdns_responder.h" +#endif #include "content/browser/data_url_loader_factory.h" #include "content/browser/declarative_performance_observer/declarative_performance_observer.h" #include "content/browser/devtools/devtools_instrumentation.h" @@ -19515,6 +19519,17 @@ RenderFrameHostImpl::CreateDeviceBoundSessionObserver() { #if BUILDFLAG(ENABLE_MDNS) void RenderFrameHostImpl::CreateMdnsResponder( mojo::PendingReceiver receiver) { + // If WebRTC is blocked by this document's Connection Allowlist, bind a no-op + // MdnsResponder so that no names are ever registered or announced, while + // still replying to every renderer request (see NoOpMdnsResponder). + // It is possible that a renderer which does not allow WebRTC could still + // reach this interface. TODO(crbug.com/571239543): Disable all WebRTC + // connections on the renderer side. + if (!IsWebRTCAllowedByConnectionAllowlist(this)) { + NoOpMdnsResponder::Create(std::move(receiver)); + return; + } + GetStoragePartition()->GetNetworkContext()->CreateMdnsResponder( std::move(receiver)); } diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn index 7ecab4f..454e0aa 100644 --- a/content/test/BUILD.gn +++ b/content/test/BUILD.gn @@ -21,6 +21,7 @@ import("//mojo/public/tools/bindings/mojom.gni") import("//net/features.gni") import("//printing/buildflags/buildflags.gni") import("//services/device/public/cpp/compute_pressure/buildflags.gni") +import("//services/network/public/cpp/features.gni") import("//services/screen_ai/buildflags/features.gni") import("//testing/test.gni") import("//third_party/blink/public/public_features.gni") @@ -3402,6 +3403,16 @@ test("content_unittests") { deps += [ "//content/browser/smart_card/emulation" ] } + if (enable_mdns) { + sources += [ "../browser/renderer_host/no_op_mdns_responder_unittest.cc" ] + } + + if (is_p2p_enabled) { + sources += + [ "../browser/renderer_host/p2p/no_op_p2p_socket_manager_unittest.cc" ] + deps += [ "//services/network/public/cpp:cpp_p2p" ] + } + if (enable_nocompile_tests) { deps += [ ":content_nocompile_tests" ] } diff --git a/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js new file mode 100644 index 0000000..cce0fd7 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js @@ -0,0 +1,74 @@ +// META: script=/common/get-host-info.sub.js +// META: script=/content-security-policy/webrtc/webrtc.js +// +// The following tests assume the policy `Connection-Allowlist: +// (response-origin);webrtc=allow` has been set. Child frames with a local +// scheme (about:blank, about:srcdoc) inherit the parent's policy container, +// and should continue to allow WebRTC, including when nested. + +const WEBRTC_HELPER = '/content-security-policy/webrtc/webrtc.js'; +// Loads webrtc.js into `win`'s own document, so `tryConnect` (and the +// RTCPeerConnection it uses) run in the child's realm, not the parent's. +function loadHelper(win) { + return new Promise((resolve, reject) => { + const script = win.document.createElement('script'); + script.src = WEBRTC_HELPER; + script.onload = resolve; + script.onerror = () => reject(new Error(`Failed to load ${WEBRTC_HELPER}`)); + win.document.head.appendChild(script); + }); +} + +async function tryConnectIn(win) { + await loadHelper(win); + return win.tryConnect(); +} + +// Appends an iframe with no `src` to `doc`. The frame stays on its initial +// about:blank document, which is usable synchronously. +function addBlankFrame(t, doc) { + const frame = doc.createElement('iframe'); + t.add_cleanup(() => frame.remove()); + doc.body.appendChild(frame); + return frame; +} + +// Appends an about:srcdoc iframe to `doc`, and waits for the srcdoc document +// to load (before that, `contentWindow` is still the initial about:blank +// document). +async function addSrcdocFrame( + t, doc, srcdoc = '

Hello World

') { + const frame = doc.createElement('iframe'); + t.add_cleanup(() => frame.remove()); + const loaded = new Promise(resolve => frame.onload = resolve); + frame.srcdoc = srcdoc; + doc.body.appendChild(frame); + await loaded; + return frame; +} + +promise_test(async t => { + assert_equals(await tryConnect(), 'allowed'); +}, 'WebRTC is allowed in the top-level document.'); + +promise_test(async t => { + const frame = addBlankFrame(t, document); + assert_equals(await tryConnectIn(frame.contentWindow), 'allowed'); +}, 'WebRTC is allowed in an about:blank child frame.'); + +promise_test(async t => { + const frame = await addSrcdocFrame(t, document); + assert_equals(await tryConnectIn(frame.contentWindow), 'allowed'); +}, 'WebRTC is allowed in an about:srcdoc child frame.'); + +promise_test(async t => { + const outer = addBlankFrame(t, document); + const inner = addBlankFrame(t, outer.contentDocument); + assert_equals(await tryConnectIn(inner.contentWindow), 'allowed'); +}, 'WebRTC is allowed in a nested about:blank frame.'); + +promise_test(async t => { + const outer = await addSrcdocFrame(t, document); + const inner = await addSrcdocFrame(t, outer.contentDocument); + assert_equals(await tryConnectIn(inner.contentWindow), 'allowed'); +}, 'WebRTC is allowed in a nested about:srcdoc frame.'); diff --git a/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js.headers b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js.headers new file mode 100644 index 0000000..e8ebc16 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js.headers @@ -0,0 +1 @@ +Connection-Allowlist: (response-origin);webrtc=allow diff --git a/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js new file mode 100644 index 0000000..e782a57 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js @@ -0,0 +1,75 @@ +// META: script=/common/get-host-info.sub.js +// META: script=/content-security-policy/webrtc/webrtc.js +// +// The following tests assume the policy `Connection-Allowlist: +// (response-origin)` has been set. WebRTC has been blocked by default. Child +// frames with a local scheme (about:blank, about:srcdoc) inherit the parent's +// policy container, and should continue to block WebRTC, including when +// nested. + +const WEBRTC_HELPER = '/content-security-policy/webrtc/webrtc.js'; +// Loads webrtc.js into `win`'s own document, so `tryConnect` (and the +// RTCPeerConnection it uses) run in the child's realm, not the parent's. +function loadHelper(win) { + return new Promise((resolve, reject) => { + const script = win.document.createElement('script'); + script.src = WEBRTC_HELPER; + script.onload = resolve; + script.onerror = () => reject(new Error(`Failed to load ${WEBRTC_HELPER}`)); + win.document.head.appendChild(script); + }); +} + +async function tryConnectIn(win) { + await loadHelper(win); + return win.tryConnect(); +} + +// Appends an iframe with no `src` to `doc`. The frame stays on its initial +// about:blank document, which is usable synchronously. +function addBlankFrame(t, doc) { + const frame = doc.createElement('iframe'); + t.add_cleanup(() => frame.remove()); + doc.body.appendChild(frame); + return frame; +} + +// Appends an about:srcdoc iframe to `doc`, and waits for the srcdoc document +// to load (before that, `contentWindow` is still the initial about:blank +// document). +async function addSrcdocFrame( + t, doc, srcdoc = '

Hello World

') { + const frame = doc.createElement('iframe'); + t.add_cleanup(() => frame.remove()); + const loaded = new Promise(resolve => frame.onload = resolve); + frame.srcdoc = srcdoc; + doc.body.appendChild(frame); + await loaded; + return frame; +} + +promise_test(async t => { + assert_equals(await tryConnect(), 'blocked'); +}, 'WebRTC is blocked in the top-level document.'); + +promise_test(async t => { + const frame = addBlankFrame(t, document); + assert_equals(await tryConnectIn(frame.contentWindow), 'blocked'); +}, 'WebRTC is blocked in an about:blank child frame.'); + +promise_test(async t => { + const frame = await addSrcdocFrame(t, document); + assert_equals(await tryConnectIn(frame.contentWindow), 'blocked'); +}, 'WebRTC is blocked in an about:srcdoc child frame.'); + +promise_test(async t => { + const outer = addBlankFrame(t, document); + const inner = addBlankFrame(t, outer.contentDocument); + assert_equals(await tryConnectIn(inner.contentWindow), 'blocked'); +}, 'WebRTC is blocked in a nested about:blank frame.'); + +promise_test(async t => { + const outer = await addSrcdocFrame(t, document); + const inner = await addSrcdocFrame(t, outer.contentDocument); + assert_equals(await tryConnectIn(inner.contentWindow), 'blocked'); +}, 'WebRTC is blocked in a nested about:srcdoc frame.'); diff --git a/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js.headers b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js.headers new file mode 100644 index 0000000..0dd9194 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js.headers @@ -0,0 +1 @@ +Connection-Allowlist: (response-origin) \ No newline at end of file