Harden Windows DLP enforcement and Electron runtime packaging

This commit is contained in:
Karl committed 2026-10-13 23:19:22 +02:00
1 parent e7d778aeac
commit 8a253eda89
120 files changed
+3569 -135

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
# Chromium 152 WebRTC binder backport
Status: source patch prepared and checked; **not compiled or runtime verified**.
Do not distribute this patch as a proven security fix until the acceptance gates
below pass against the resulting binary.
## Exact basis and provenance
The adjacent patch targets Chromium `152.0.7977.130`, used by Electron `44.7.0`.
It backports Chromium commit
[`46fcf3faed9db394de7046a100301f64c2842f0e`](https://chromium.googlesource.com/chromium/src/+/46fcf3faed9db394de7046a100301f64c2842f0e)
(CL8485457, bug565837691). Upstream files retain their Chromium copyright notices.
The upstream change fixes a native renderer termination when nested local-scheme
frames request a missing WebRTC binder under `Connection-Allowlist`. It registers
the binders consistently, checks the current document policy when binding, and
uses no-op mDNS/socket implementations to fail without creating network sockets.
It is a temporary transport denial, not WebRTC payload inspection.
Only two context adaptations were needed: insert the policy helper before the
older utilities file's namespace close, and place the conditional mDNS include
beside the existing connection-policy include. The original implementation,
unit tests, WPT tests and GN entries are preserved. The older public
`RenderFrameHost` header exposes both APIs used by the helper:
`HasPolicyContainerHost()` and `GetConnectionAllowlists()`.
On 2026-10-13, the 16 exact touched paths were retrieved individually from the
official Chromium tag into an ignored scratch repository. Unmodified tag files
were restored, with LF line endings, and this command completed with exit0:
```powershell
git -C artifacts/rtc-patch-check apply --check C:/Users/karl/Safetynet/docs/native-runtime/chromium-152-webrtc-binder-backport.patch
```
This proves textual applicability to the tagged files. It does not establish
compilation, compatibility with Electron's existing patches, or runtime safety.
Repeat the check against the complete Electron-patched source checkout.
## Host readiness
Measured host: Ryzen7800X3D, eight exposed logical processors, 16GB RAM (about
4.5GB free during inspection), and 266.6GiB free on NTFS C:. Installed VS2022
Build Tools17.14 includes x64 C++ tools but lacks the ATL/MFC component. SDK
`rc.exe` is26100.7705; `symstore.exe` is28000.2957. No native checkout or
depot_tools/gclient/GN/Ninja installation was found on PATH.
The [version-specific Chromium requirements](https://chromium.googlesource.com/chromium/src/+/152.0.7977.130/docs/windows_build_instructions.md)
specify at least8GB RAM and100GB disk, recommend more than16GB RAM, and require
C++ plus ATL/MFC and SDK26100.7705. This host meets the minimum resources but
requires toolchain completion. Plan substantially more memory and disk headroom
for comfortable builds; actual full-checkout and output size remains unmeasured.
Do not automatically disable endpoint antivirus or change global Git settings.
## Build procedure
Follow [Electron Windows prerequisites](https://www.electronjs.org/docs/latest/development/build-instructions-windows)
and [Electron GN/patch instructions](https://www.electronjs.org/docs/latest/development/build-instructions-gn).
Use a separate short path such as `C:/src/electron44`, outside this application.
Install missing ATL/MFC and depot_tools first. No large downloads or dependency
changes were performed while preparing this review artifact.
1. Set `DEPOT_TOOLS_WIN_TOOLCHAIN=0` in the build process and prepend depot_tools
to that process's PATH. Keep the application workspace unchanged.
2. Configure gclient with unmanaged `src/electron` pointing at the official
Electron repository. Bootstrap its source, check out exact `v44.7.0` in
`src/electron`, then sync with branch heads and tags. Confirm its DEPS pins
Chromium152.0.7977.130 before applying anything.
3. From `src`, run `git apply --check <absolute-patch-path>` against the already
Electron-patched checkout, then apply. Any failure requires review; do not
silently drop hunks. Export the change through Electron's patch system
(`e patches`) so future synchronizations reproduce it.
4. Generate a release configuration using
`import("//electron/build/args/release.gn") target_cpu="x64"`.
For this constrained host, consider `symbol_level=0`,
`blink_symbol_level=0`, `v8_symbol_level=0`, and local execution. These reduce
debugging detail; keep a separately instrumented build if native investigation
requires symbols.
5. Build `ninja -C out/Release -j2 electron`. Build the relevant Chromium native
test target as well; low parallelism is an initial resource choice, not a
measured guarantee against memory exhaustion.
6. Only after verification, package with
`ninja -C out/Release electron:electron_dist_zip` and pin artifact hash,
Electron/Chromium versions and backport revision in release provenance.
## Acceptance gates
- Compile and run `NoOpMdnsResponderTest.*` and `NoOpP2PSocketManagerTest.*`.
- Execute the upstream local-scheme allow/block WPT cases.
- Run real paired WebRTC data-channel positive controls in initial about:blank,
srcdoc, blob and cross-origin frames; prove exact synthetic bytes arrive.
- Under the actual managed response-header function, repeat the same attempts:
no data delivery, no renderer termination, no hanging harness, and successful
benign HTTP POSTs. Exercise the initial synchronous iframe window before any
navigation or preload; constructor availability alone is not a pass.
- Verify native socket denial without relying on the JavaScript preload. Check
redirects and a site-supplied `webrtc=allow` header cannot relax managed policy.
- Run the complete Windows DLP MVP native acceptance suite and inspect actual
exported OpenTelemetry traces, durable deny events and exporter failures.
## Alternative runtime status
No tested prebuilt alternative is verified. Electron46 nightly20261013 with
Chromium157.0.8091.0 also crashes under the same blocked about:blank/srcdoc
attempts; its tagged binder source lacks the no-op implementation. A later
binary is acceptable only after source inclusion checks and all native gates
above. Current ignored artifacts retain the original crashes and positive
controls; an upgrade must not overwrite that evidence.
@@ -0,0 +1,903 @@
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<network::mojom::P2PSocketManager> 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<RenderProcessHostImpl*>(frame->GetProcess())
->BindP2PSocketManager(
frame->GetIsolationInfoForSubresources().network_anonymization_key(),
@@ -991,26 +1005,9 @@ void PopulateBinderMapWithContext(
map->Add<blink::mojom::IdleManager>(
&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<network::mojom::MdnsResponder>(
+ map->Add<network::mojom::MdnsResponder>(
&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<network::mojom::P2PSocketManager>(&BindSocketManager);
- }
+ map->Add<network::mojom::P2PSocketManager>(&BindSocketManager);
#endif // BUILDFLAG(IS_P2P_ENABLED)
map->Add<blink::mojom::PeerConnectionTrackerHost>(
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<network::ConnectionAllowlist>& 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 <memory>
+#include <string>
+#include <utility>
+
+#include "mojo/public/cpp/bindings/self_owned_receiver.h"
+
+namespace content {
+
+// static
+void NoOpMdnsResponder::Create(
+ mojo::PendingReceiver<network::mojom::MdnsResponder> receiver) {
+ mojo::MakeSelfOwnedReceiver(std::make_unique<NoOpMdnsResponder>(),
+ 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<network::mojom::MdnsResponder> 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 <string>
+
+#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<network::mojom::MdnsResponder> responder_;
+};
+
+// CreateNameForAddress() must always run its callback, without creating a name
+// or scheduling an announcement.
+TEST_F(NoOpMdnsResponderTest, CreateNameForAddressFails) {
+ base::test::TestFuture<const std::string&, bool> 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<const std::string&, bool> 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<bool, bool> 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<const std::string&, bool> 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<bool, bool> 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 <memory>
+#include <utility>
+
+#include "mojo/public/cpp/bindings/self_owned_receiver.h"
+#include "net/base/ip_address.h"
+
+namespace content {
+
+// static
+void NoOpP2PSocketManager::Create(
+ mojo::PendingReceiver<network::mojom::P2PSocketManager> receiver) {
+ mojo::MakeSelfOwnedReceiver(std::make_unique<NoOpP2PSocketManager>(),
+ std::move(receiver));
+}
+
+NoOpP2PSocketManager::NoOpP2PSocketManager() = default;
+NoOpP2PSocketManager::~NoOpP2PSocketManager() = default;
+
+void NoOpP2PSocketManager::StartNetworkNotifications(
+ mojo::PendingRemote<network::mojom::P2PNetworkNotificationClient> 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<net::AddressFamily> 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<base::UnguessableToken>& devtools_token,
+ mojo::PendingRemote<network::mojom::P2PSocketClient> client,
+ mojo::PendingReceiver<network::mojom::P2PSocket> 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 <optional>
+#include <string>
+
+#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<network::mojom::P2PSocketManager> receiver);
+
+ NoOpP2PSocketManager();
+ NoOpP2PSocketManager(const NoOpP2PSocketManager&) = delete;
+ NoOpP2PSocketManager& operator=(const NoOpP2PSocketManager&) = delete;
+ ~NoOpP2PSocketManager() override;
+
+ // network::mojom::P2PSocketManager:
+ void StartNetworkNotifications(
+ mojo::PendingRemote<network::mojom::P2PNetworkNotificationClient> client)
+ override;
+ void GetHostAddress(const std::string& host_name,
+ std::optional<net::AddressFamily> 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<base::UnguessableToken>& devtools_token,
+ mojo::PendingRemote<network::mojom::P2PSocketClient> client,
+ mojo::PendingReceiver<network::mojom::P2PSocket> 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::mojom::P2PNetworkNotificationClient>
+ 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 <optional>
+#include <vector>
+
+#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<network::mojom::P2PNetworkNotificationClient>
+ 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<std::vector<net::NetworkInterface>,
+ net::IPAddress,
+ net::IPAddress>&
+ network_list_future() {
+ return network_list_future_;
+ }
+
+ // network::mojom::P2PNetworkNotificationClient:
+ void NetworkListChanged(
+ const std::vector<net::NetworkInterface>& 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<network::mojom::P2PNetworkNotificationClient> receiver_{this};
+ base::test::TestFuture<std::vector<net::NetworkInterface>,
+ 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<network::mojom::P2PSocketManager> 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<const std::vector<net::IPAddress>&> 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<const std::vector<net::IPAddress>&> 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<network::mojom::P2PSocketClient> client_receiver;
+ mojo::PendingRemote<network::mojom::P2PSocketClient> client =
+ client_receiver.InitWithNewPipeAndPassRemote();
+
+ mojo::Remote<network::mojom::P2PSocket> socket;
+ base::test::TestFuture<void> 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<network::mojom::MdnsResponder> 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 = '<!doctype html><body><p>Hello World</p></body>') {
+ 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 = '<!doctype html><body><p>Hello World</p></body>') {
+ 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