CVE-2026-79099
Overview
Background
- Direct Sockets API
- a Chrome capability (
third_party/blink/renderer/modules/direct_sockets/) that lets Isolated Web Apps open raw TCP/UDP sockets, including bound and connected UDP sockets. - `direct-sockets-multicast` Permissions Policy
- the per-frame policy that authorizes a document to send to or join IP multicast groups over Direct Sockets.
- `allow_multicast_`
- the per-socket flag on
RestrictedUDPSocketderived from the multicast permission policy, previously enforced only onJoinGroup()andLeaveGroup(). - `net::IPAddress::IsMulticast`
- the address predicate used to detect a multicast destination for a UDP send or connect.
Root Cause Analysis
RestrictedUDPSocket consulted allow_multicast_ only when gating JoinGroup() and LeaveGroup(), so the multicast authorization invariant — that a document may direct multicast traffic only when granted the direct-sockets-multicast Permissions Policy — was never enforced on the actual data path. For bound sockets, RestrictedUDPSocket::SendTo (and its resolved-hostname continuation) forwarded the datagram to udp_socket_->SendTo() regardless of whether dest_addr.address().IsMulticast(), and for connected sockets DirectSocketsServiceImpl resolved a hostname and called Connect() without checking the peer address.
The fix restores the invariant by checking IsMulticast() against the multicast permission (allow_multicast_ for bound sockets, IsMulticastAllowed(context_) for connected ones) in each path, gated by the default-on kDirectSocketsUdpSendRequireMulticastPermissionPolicy feature, and returning the new net::ERR_MULTICAST_NOT_ALLOWED when the destination is multicast but the policy is absent. A CHECK in socket_factory.cc backstops the connected path so a multicast address can never reach Connect() without permission.
JoinGroup/LeaveGroup) while leaving the send and connect paths ungated; the fix adds the missing IsMulticast()-versus-permission check to both SendTo and the connected-socket resolution path.Attack Path
- Obtain socket access
A document with Direct Sockets access but without the
direct-sockets-multicastPermissions Policy opens a bound or connected UDP socket. - Target a multicast address
The document supplies a multicast destination (an IP literal or a hostname resolving to one) to a
send()or connect operation. - Bypass the ungated path
Because
RestrictedUDPSocket::SendTo/OnResolveCompleteForSendToandDirectSocketsServiceImplnever checkedIsMulticast(), the datagram orConnect()proceeds despite the missing policy. - Reach multicast group Traffic is delivered to the multicast group, letting the unauthorized origin transmit onto local-network multicast destinations it should not reach.
Impact Assessment
direct-sockets-multicast Permissions Policy, effectively bypassing the authorization boundary that the multicast policy is meant to enforce. The action occurs from the renderer-driven Direct Sockets path executed in the network service (RestrictedUDPSocket, DirectSocketsServiceImpl), with the precondition that the origin already has Direct Sockets access (an Isolated Web App context) but lacks the multicast grant.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/direct_sockets/direct_sockets_apitest.cc |
modified |
Files Changed
services/network/public/cpp/features.hcontent/browser/direct_sockets/direct_sockets_udp_browsertest.ccservices/network/network_context_unittest.ccservices/network/socket_factory.ccchrome/browser/about_flags.ccchrome/browser/direct_sockets/direct_sockets_apitest.ccchrome/browser/flag_descriptions.hchrome/browser/flag-metadata.jsoncontent/test/data/direct_sockets/udp.jsnet/base/net_error_list.hthird_party/blink/renderer/modules/direct_sockets/udp_socket.cctools/metrics/histograms/enums.xmlservices/network/restricted_udp_socket.cccontent/browser/direct_sockets/direct_sockets_service_impl.ccthird_party/blink/renderer/modules/direct_sockets/udp_writable_stream_wrapper.ccservices/network/public/cpp/features.cc
Audit Directions
- Partial gate coverageWhen a capability flag (like
allow_multicast_) gates one operation, audit every sibling operation on the same resource (SendTo,Connect,JoinGroup,LeaveGroup) to confirm all data paths enforce it, not just membership calls. - Post-resolution checksVerify that permission checks apply after hostname resolution (
OnResolveCompleteForSendTo,OnResolveCompleteForUDPSocket), since a benign hostname can resolve to a multicast or otherwise privileged address. - Permission-policy enforcement pointFor Direct Sockets and similar Permissions-Policy-gated APIs, confirm the policy is enforced in the network service / browser process rather than trusting the renderer, so a compromised or non-compliant renderer cannot skip the
IsMulticast()-style check.
Patch
From 405665065117459f6a72161b865c8244742b21c7 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Horo <[email protected]> Date: Mon, 13 Jul 2026 03:14:09 -0700 Subject: [PATCH] [direct sockets] Gate UDP multicast send/connect on permission policy RestrictedUDPSocket gated JoinGroup() and LeaveGroup() on the per-socket allow_multicast_ flag, but SendTo() and Connect() allowed multicast destination addresses even if the 'direct-sockets-multicast' Permissions Policy was absent. This CL introduces the following changes: - Adds feature flag kDirectSocketsUdpSendRequireMulticastPermissionPolicy (enabled by default) and registers a chrome://flags entry to allow bypassing the restriction. - Defines a new net error code net::ERR_MULTICAST_NOT_ALLOWED (-190). - Checks multicast permission policy in DirectSocketsServiceImpl when resolving hostnames for connected UDP sockets, returning net::ERR_MULTICAST_NOT_ALLOWED if missing. - Checks multicast permission policy in RestrictedUDPSocket::SendTo and OnResolveCompleteForSendTo for bound UDP sockets. - Emits informative console error messages in renderer UDPSocket and UDPWritableStreamWrapper when ERR_MULTICAST_NOT_ALLOWED occurs. - Updates unit tests and browser tests to run parameterized test suites covering enabled and disabled feature states, IP literals, and hostname resolution. Bug: 517608454 Change-Id: Id9fc8d4f421424beb77826bb79750462fbe4b3f1 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8037701 Commit-Queue: Tsuyoshi Horo <[email protected]> Reviewed-by: Vlad Krot <[email protected]> Cr-Commit-Position: refs/heads/main@{#1661023} --- diff --git a/chrome/browser/about_flags.cc b/chrome/browser/about_flags.cc index b23651c6..72c457e3 100644 --- a/chrome/browser/about_flags.cc +++ b/chrome/browser/about_flags.cc @@ -5473,6 +5473,15 @@ flag_descriptions::kDirectSocketsInSharedWorkersName, flag_descriptions::kDirectSocketsInSharedWorkersDescription, kOsDesktop, FEATURE_VALUE_TYPE(blink::features::kDirectSocketsInSharedWorkers)}, + {"direct-sockets-udp-send-require-multicast-permission-policy", + flag_descriptions:: + kDirectSocketsUdpSendRequireMulticastPermissionPolicyName, + flag_descriptions:: + kDirectSocketsUdpSendRequireMulticastPermissionPolicyDescription, + kOsDesktop, + FEATURE_VALUE_TYPE( + network::features:: + kDirectSocketsUdpSendRequireMulticastPermissionPolicy)}, #if BUILDFLAG(IS_CHROMEOS) {"enable-chromeos-isolated-web-app-set-shape", flag_descriptions::kEnableChromeOSIsolatedWebAppSetShapeName, diff --git a/chrome/browser/flag-metadata.json b/chrome/browser/flag-metadata.json index 7f42d3b0..f571ee3b 100644 --- a/chrome/browser/flag-metadata.json +++ b/chrome/browser/flag-metadata.json @@ -2555,6 +2555,11 @@ "expiry_milestone": 170 }, { + "name": "direct-sockets-udp-send-require-multicast-permission-policy", + "owners": [ "//content/browser/direct_sockets/OWNERS" ], + "expiry_milestone": 170 + }, + { "name": "disable-accelerated-2d-canvas", "owners": [ "[email protected]" ], "expiry_milestone": -1 diff --git a/chrome/browser/flag_descriptions.h b/chrome/browser/flag_descriptions.h index d005361..91e7065 100644 --- a/chrome/browser/flag_descriptions.h +++ b/chrome/browser/flag_descriptions.h @@ -2080,6 +2080,14 @@ "Enables access to the Direct Sockets API in shared workers. See " "https://github.com/WICG/direct-sockets for details."; +inline constexpr char + kDirectSocketsUdpSendRequireMulticastPermissionPolicyName[] = + "Direct Sockets UDP send require multicast permission policy"; +inline constexpr char + kDirectSocketsUdpSendRequireMulticastPermissionPolicyDescription[] = + "Requires the 'direct-sockets-multicast' permissions policy to send or " + "connect to multicast addresses using the Direct Sockets API."; + inline constexpr char kEnableIsolatedWebAppUnmanagedInstallName[] = "Enable Isolated Web App unmanaged installation"; inline constexpr char kEnableIsolatedWebAppUnmanagedInstallDescription[] = diff --git a/content/browser/direct_sockets/direct_sockets_service_impl.cc b/content/browser/direct_sockets/direct_sockets_service_impl.cc index 5f023f9..0b24bef 100644 --- a/content/browser/direct_sockets/direct_sockets_service_impl.cc +++ b/content/browser/direct_sockets/direct_sockets_service_impl.cc @@ -39,6 +39,7 @@ #include "net/base/net_errors.h" #include "net/base/network_anonymization_key.h" #include "net/traffic_annotation/network_traffic_annotation.h" +#include "services/network/public/cpp/features.h" #include "services/network/public/cpp/ip_address_space_util.h" #include "services/network/public/cpp/simple_host_resolver.h" #include "services/network/public/mojom/network_context.mojom.h" @@ -769,6 +770,15 @@ DCHECK(!resolved_addresses.empty()); + const auto& peer_addr = resolved_addresses.front(); + if (base::FeatureList::IsEnabled( + network::features:: + kDirectSocketsUdpSendRequireMulticastPermissionPolicy) && + !IsMulticastAllowed(context_) && peer_addr.address().IsMulticast()) { + FulfillWithError(std::move(callback), net::ERR_MULTICAST_NOT_ALLOWED); + return; + } + auto socket_options = network::mojom::UDPSocketOptions::New(); if (options->send_buffer_size.has_value()) { socket_options->send_buffer_size = *options->send_buffer_size; @@ -789,7 +799,6 @@ auto params = network::mojom::RestrictedUDPSocketParams::New(); params->socket_options = std::move(socket_options); - const auto& peer_addr = resolved_addresses.front(); auto finish_callback = base::BindOnce( [](OpenConnectedUDPSocketCallback callback, net::IPEndPoint peer_addr, int result, const std::optional<net::IPEndPoint>& local_addr) { diff --git a/net/base/net_error_list.h b/net/base/net_error_list.h index b871457..fa9cf124 100644 --- a/net/base/net_error_list.h +++ b/net/base/net_error_list.h @@ -486,6 +486,10 @@ // message control data exceeds size threshold). NET_ERROR(CONTROL_MSG_TOO_BIG, -189) +// Direct Sockets attempted to connect to or send a packet to a multicast +// address without 'direct-sockets-multicast' permissions policy. +NET_ERROR(MULTICAST_NOT_ALLOWED, -190) + // Certificate error codes // // The values of certificate error codes must be consecutive. diff --git a/services/network/public/cpp/features.cc b/services/network/public/cpp/features.cc index 4288dfb..7a22368 100644 --- a/services/network/public/cpp/features.cc +++ b/services/network/public/cpp/features.cc @@ -666,6 +666,9 @@ &kRestrictForbiddenSecurityHeaders, false); +BASE_FEATURE(kDirectSocketsUdpSendRequireMulticastPermissionPolicy, + base::FEATURE_ENABLED_BY_DEFAULT); + BASE_FEATURE(kBrowserInitiatedFileUploadValidation, base::FEATURE_ENABLED_BY_DEFAULT); diff --git a/services/network/public/cpp/features.h b/services/network/public/cpp/features.h index cae8deae..db3e958 100644 --- a/services/network/public/cpp/features.h +++ b/services/network/public/cpp/features.h @@ -448,6 +448,11 @@ COMPONENT_EXPORT(NETWORK_CPP_FLAGS_AND_SWITCHES) BASE_DECLARE_FEATURE_PARAM(bool, kRestrictForbiddenSecurityHeadersDump); +// When enabled, sending to a multicast address via Direct Sockets requires the +// 'direct-sockets-multicast' Permissions Policy. +COMPONENT_EXPORT(NETWORK_CPP_FLAGS_AND_SWITCHES) +BASE_DECLARE_FEATURE(kDirectSocketsUdpSendRequireMulticastPermissionPolicy); + // Enables validating browser-initiated file upload requests from the network // service. COMPONENT_EXPORT(NETWORK_CPP_FLAGS_AND_SWITCHES) diff --git a/services/network/restricted_udp_socket.cc b/services/network/restricted_udp_socket.cc index 0c720090..4914334 100644 --- a/services/network/restricted_udp_socket.cc +++ b/services/network/restricted_udp_socket.cc @@ -10,6 +10,7 @@ #include "net/base/ip_endpoint.h" #include "net/base/net_errors.h" #include "net/base/network_anonymization_key.h" +#include "services/network/public/cpp/features.h" #include "services/network/public/cpp/simple_host_resolver.h" #include "services/network/public/mojom/host_resolver.mojom.h" #include "services/network/udp_socket.h" @@ -95,6 +96,12 @@ SendToCallback callback) { // If a raw IP address is supplied, call SendTo() immediately. if (net::IPAddress address; address.AssignFromIPLiteral(dest_addr.host())) { + if (base::FeatureList::IsEnabled( + features::kDirectSocketsUdpSendRequireMulticastPermissionPolicy) && + !allow_multicast_ && address.IsMulticast()) { + std::move(callback).Run(net::ERR_MULTICAST_NOT_ALLOWED); + return; + } udp_socket_->SendTo(net::IPEndPoint(std::move(address), dest_addr.port()), data, traffic_annotation_, std::move(callback)); return; @@ -131,8 +138,16 @@ return; } - udp_socket_->SendTo(resolved_addresses.front(), std::move(data), - traffic_annotation_, std::move(callback)); + const net::IPEndPoint& dest_addr = resolved_addresses.front(); + if (base::FeatureList::IsEnabled( + features::kDirectSocketsUdpSendRequireMulticastPermissionPolicy) && + !allow_multicast_ && dest_addr.address().IsMulticast()) { + std::move(callback).Run(net::ERR_MULTICAST_NOT_ALLOWED); + return; + } + + udp_socket_->SendTo(dest_addr, std::move(data), traffic_annotation_, + std::move(callback)); } } // namespace network diff --git a/services/network/socket_factory.cc b/services/network/socket_factory.cc index 244bf8e..9e16900 100644 --- a/services/network/socket_factory.cc +++ b/services/network/socket_factory.cc @@ -22,6 +22,7 @@ #include "net/ssl/ssl_config.h" #include "net/ssl/ssl_config_service.h" #include "net/url_request/url_request_context.h" +#include "services/network/public/cpp/features.h" #include "services/network/public/cpp/simple_host_resolver.h" #include "services/network/restricted_udp_socket.h" #include "services/network/tls_client_socket.h" @@ -72,6 +73,12 @@ std::move(callback)); break; case mojom::RestrictedUDPSocketMode::CONNECTED: + if (base::FeatureList::IsEnabled( + features:: + kDirectSocketsUdpSendRequireMulticastPermissionPolicy)) { + // Checked in DirectSocketsServiceImpl::OnResolveCompleteForUDPSocket. + CHECK(allow_multicast || !addr.address().IsMulticast()); + } udp_socket->Connect(addr, /*options=*/ params ? std::move(params->socket_options) : nullptr, std::move(callback)); diff --git a/third_party/blink/renderer/modules/direct_sockets/udp_socket.cc b/third_party/blink/renderer/modules/direct_sockets/udp_socket.cc index df8f6b8b..4cb1574 100644 --- a/third_party/blink/renderer/modules/direct_sockets/udp_socket.cc +++ b/third_party/blink/renderer/modules/direct_sockets/udp_socket.cc @@ -19,6 +19,7 @@ #include "third_party/blink/renderer/core/core_probes_inl.h" #include "third_party/blink/renderer/core/dom/dom_exception.h" #include "third_party/blink/renderer/core/execution_context/execution_context.h" +#include "third_party/blink/renderer/core/inspector/console_message.h" #include "third_party/blink/renderer/core/inspector/protocol/network.h" #include "third_party/blink/renderer/core/streams/readable_stream.h" #include "third_party/blink/renderer/core/streams/writable_stream.h" @@ -522,6 +523,21 @@ GetClosedProperty().Reject(ScriptValue(GetScriptState()->GetIsolate(), exception->ToV8(GetScriptState()))); + // If connecting to a multicast address was rejected due to missing + // 'direct-sockets-multicast' Permissions Policy, report a clear console + // error message so web developers understand why the socket opening failed. + // Note: This check (and console message) was added after the API shipped to + // ease transition for developers who might have relied on the previous + // behavior. + if (error == net::ERR_MULTICAST_NOT_ALLOWED && GetExecutionContext()) { + GetExecutionContext()->AddConsoleMessage(MakeGarbageCollected< + ConsoleMessage>( + mojom::blink::ConsoleMessageSource::kJavaScript, + mojom::blink::ConsoleMessageLevel::kError, + "DirectSockets UDP socket opening failed. Connecting to a multicast " + "address requires 'direct-sockets-multicast' permissions policy.")); + } + abort_net_error_ = error; } diff --git a/third_party/blink/renderer/modules/direct_sockets/udp_writable_stream_wrapper.cc b/third_party/blink/renderer/modules/direct_sockets/udp_writable_stream_wrapper.cc index 4b23761..dd4e6f5 100644 --- a/third_party/blink/renderer/modules/direct_sockets/udp_writable_stream_wrapper.cc +++ b/third_party/blink/renderer/modules/direct_sockets/udp_writable_stream_wrapper.cc @@ -21,6 +21,7 @@ #include "third_party/blink/renderer/core/execution_context/execution_context.h" #include "third_party/blink/renderer/core/execution_context/execution_context_lifecycle_observer.h" #include "third_party/blink/renderer/core/frame/report.h" +#include "third_party/blink/renderer/core/inspector/console_message.h" #include "third_party/blink/renderer/core/streams/underlying_sink_base.h" #include "third_party/blink/renderer/core/streams/writable_stream.h" #include "third_party/blink/renderer/core/streams/writable_stream_default_controller.h" @@ -218,6 +219,23 @@ // ScriptValue. ScriptState::Scope scope{script_state}; + // If sending to a multicast address was rejected due to missing + // 'direct-sockets-multicast' Permissions Policy, report a clear console + // error message so web developers understand why the stream was aborted. + // Note: This check (and console message) was added after the API shipped to + // ease transition for developers who might have relied on the previous + // behavior. + if (error_code == net::ERR_MULTICAST_NOT_ALLOWED && + ExecutionContext::From(script_state)) { + ExecutionContext::From(script_state) + ->AddConsoleMessage(MakeGarbageCollected<ConsoleMessage>( + mojom::blink::ConsoleMessageSource::kJavaScript, + mojom::blink::ConsoleMessageLevel::kError, + "DirectSockets UDP packet sending failed with " + "ERR_MULTICAST_NOT_ALLOWED. Sending to a multicast address " + "requires 'direct-sockets-multicast' permissions policy.")); + } + auto message = String{"Stream aborted by the remote: " + net::ErrorToString(error_code)}; diff --git a/tools/metrics/histograms/enums.xml b/tools/metrics/histograms/enums.xml index b0e7b05..e308674 100644 --- a/tools/metrics/histograms/enums.xml +++ b/tools/metrics/histograms/enums.xml @@ -2031,6 +2031,7 @@ <int value="-202" label="CERT_AUTHORITY_INVALID"/> <int value="-201" label="CERT_DATE_INVALID"/> <int value="-200" label="CERT_COMMON_NAME_INVALID"/> + <int value="-190" label="MULTICAST_NOT_ALLOWED"/> <int value="-189" label="CONTROL_MSG_TOO_BIG"/> <int value="-188" label="PROXY_DELEGATE_CANCELED_CONNECT_RESPONSE"/> <int value="-187" label="PROXY_DELEGATE_CANCELED_CONNECT_REQUEST"/> @@ -8572,6 +8573,8 @@ <int value="-1928198763" label="enable-async-dns"/> <int value="-1927968708" label="OmniboxPedalsAndroidBatch1:disabled"/> <int value="-1927080237" label="Vulkan:enabled"/> + <int value="-1926746234" + label="DirectSocketsUdpSendRequireMulticastPermissionPolicy:disabled"/> <int value="-1926236767" label="DynamicColorButtonsAndroid:enabled"/> <int value="-1926124919" label="WebViewOriginTrials:enabled"/> <int value="-1925573731" label="CreateShortcutIgnoresManifest:enabled"/> @@ -16332,6 +16335,8 @@ label="AutofillEnableMerchantOptOutErrorDialog:enabled"/> <int value="560062958" label="Cr2023MacFontSmoothing:disabled"/> <int value="560369327" label="LauncherImageSearch:disabled"/> + <int value="560843639" + label="DirectSocketsUdpSendRequireMulticastPermissionPolicy:enabled"/> <int value="560953355" label="ExtractRelatedSearchesFromPrefetchedZPSResponse:disabled"/> <int value="561162418" label="DrawKeyNativeEdgeToEdge:disabled"/> @@ -22218,6 +22223,7 @@ <int value="187" label="PROXY_DELEGATE_CANCELED_CONNECT_REQUEST"/> <int value="188" label="PROXY_DELEGATE_CANCELED_CONNECT_RESPONSE"/> <int value="189" label="CONTROL_MSG_TOO_BIG"/> + <int value="190" label="MULTICAST_NOT_ALLOWED"/> <int value="200" label="CERT_COMMON_NAME_INVALID"/> <int value="201" label="CERT_DATE_INVALID"/> <int value="202" label="CERT_AUTHORITY_INVALID"/> diff --git a/chrome/browser/direct_sockets/direct_sockets_apitest.cc b/chrome/browser/direct_sockets/direct_sockets_apitest.cc index 4ecfa8f..095fff9c7 100644 --- a/chrome/browser/direct_sockets/direct_sockets_apitest.cc +++ b/chrome/browser/direct_sockets/direct_sockets_apitest.cc @@ -773,6 +773,56 @@ web_app::IsolatedWebAppUrlInfo url_info = app->Install(profile()).value(); return OpenApp(url_info.app_id()); } + + void TestMulticastSend(bool with_pna, + bool with_multicast, + bool connected_else_bound, + std::string_view expected_result) { + content::RenderFrameHost* app_frame = + InstallAndOpenIsolatedWebApp(with_pna, with_multicast); + + std::string script; + if (connected_else_bound) { + script = R"( + (async () => { + try { + const socket = new UDPSocket({ + remoteAddress: '239.255.255.250', + remotePort: 1900 + }); + await socket.opened; + await socket.close(); + return 'success'; + } catch (e) { + return e.message; + } + })() + )"; + } else { + script = R"( + (async () => { + try { + const socket = new UDPSocket({ + localAddress: '0.0.0.0' + }); + const { writable } = await socket.opened; + const writer = writable.getWriter(); + await writer.write({ + data: new Uint8Array([1, 2, 3]), + remoteAddress: '239.255.255.250', + remotePort: 1900 + }); + writer.releaseLock(); + await socket.close(); + return 'success'; + } catch (e) { + return e.message; + } ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/direct_sockets/direct_sockets_apitest.cc b/chrome/browser/direct_sockets/direct_sockets_apitest.cc
index 4ecfa8f..095fff9c7 100644
--- a/chrome/browser/direct_sockets/direct_sockets_apitest.cc
+++ b/chrome/browser/direct_sockets/direct_sockets_apitest.cc
@@ -773,6 +773,56 @@
web_app::IsolatedWebAppUrlInfo url_info = app->Install(profile()).value();
return OpenApp(url_info.app_id());
}
+
+ void TestMulticastSend(bool with_pna,
+ bool with_multicast,
+ bool connected_else_bound,
+ std::string_view expected_result) {
+ content::RenderFrameHost* app_frame =
+ InstallAndOpenIsolatedWebApp(with_pna, with_multicast);
+
+ std::string script;
+ if (connected_else_bound) {
+ script = R"(
+ (async () => {
+ try {
+ const socket = new UDPSocket({
+ remoteAddress: '239.255.255.250',
+ remotePort: 1900
+ });
+ await socket.opened;
+ await socket.close();
+ return 'success';
+ } catch (e) {
+ return e.message;
+ }
+ })()
+ )";
+ } else {
+ script = R"(
+ (async () => {
+ try {
+ const socket = new UDPSocket({
+ localAddress: '0.0.0.0'
+ });
+ const { writable } = await socket.opened;
+ const writer = writable.getWriter();
+ await writer.write({
+ data: new Uint8Array([1, 2, 3]),
+ remoteAddress: '239.255.255.250',
+ remotePort: 1900
+ });
+ writer.releaseLock();
+ await socket.close();
+ return 'success';
+ } catch (e) {
+ return e.message;
+ }
+ })()
+ )";
+ }
+ EXPECT_EQ(expected_result, EvalJs(app_frame, script));
+ }
};
using IsolatedWebAppMulticastApiTest = IsolatedWebAppApiTest;
@@ -1835,25 +1885,55 @@
}
IN_PROC_BROWSER_TEST_F(IsolatedWebAppApiTest,
- MulticastSendWithoutPrivatePolicyBypass) {
- content::RenderFrameHost* app_frame = InstallAndOpenIsolatedWebApp(
- /*with_pna=*/false, /*with_multicast=*/false);
+ MulticastConnectedWithPnaAndMulticastPolicy) {
+ TestMulticastSend(/*with_pna=*/true, /*with_multicast=*/true,
+ /*connected_else_bound=*/true, "success");
+}
- const std::string script = R"(
- (async () => {
- try {
- const socket = new UDPSocket({ remoteAddress: '239.255.255.250', remotePort: 1900 });
- const { writable } = await socket.opened;
- const writer = writable.getWriter();
- await writer.write({ data: new TextEncoder().encode("M-SEARCH * HTTP/1.1\r\n...") });
- writer.releaseLock();
- await socket.close();
- return 'success';
- } catch (e) {
- return e.message;
- }
- })()
- )";
- EXPECT_EQ("Access to local network is blocked.", EvalJs(app_frame, script));
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppApiTest,
+ MulticastBoundWithPnaAndMulticastPolicy) {
+ TestMulticastSend(/*with_pna=*/true, /*with_multicast=*/true,
+ /*connected_else_bound=*/false, "success");
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppApiTest,
+ MulticastConnectedWithPnaWithoutMulticastPolicy) {
+ TestMulticastSend(/*with_pna=*/true, /*with_multicast=*/false,
+ /*connected_else_bound=*/true, "Network Error.");
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppApiTest,
+ MulticastBoundWithPnaWithoutMulticastPolicy) {
+ TestMulticastSend(
+ /*with_pna=*/true, /*with_multicast=*/false,
+ /*connected_else_bound=*/false,
+ "Stream aborted by the remote: net::ERR_MULTICAST_NOT_ALLOWED");
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppApiTest,
+ MulticastConnectedWithoutPnaWithMulticastPolicy) {
+ TestMulticastSend(/*with_pna=*/false, /*with_multicast=*/true,
+ /*connected_else_bound=*/true,
+ "Access to local network is blocked.");
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppApiTest,
+ MulticastBoundWithoutPnaWithMulticastPolicy) {
+ TestMulticastSend(/*with_pna=*/false, /*with_multicast=*/true,
+ /*connected_else_bound=*/false,
+ "Access to local network is blocked.");
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppApiTest,
+ MulticastConnectedWithoutPnaWithoutMulticastPolicy) {
+ TestMulticastSend(/*with_pna=*/false, /*with_multicast=*/false,
+ /*connected_else_bound=*/true, "Network Error.");
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppApiTest,
+ MulticastBoundWithoutPnaWithoutMulticastPolicy) {
+ TestMulticastSend(/*with_pna=*/false, /*with_multicast=*/false,
+ /*connected_else_bound=*/false,
+ "Access to local network is blocked.");
}
} // namespace
diff --git a/content/browser/direct_sockets/direct_sockets_udp_browsertest.cc b/content/browser/direct_sockets/direct_sockets_udp_browsertest.cc
index ac1b529..0b07f45 100644
--- a/content/browser/direct_sockets/direct_sockets_udp_browsertest.cc
+++ b/content/browser/direct_sockets/direct_sockets_udp_browsertest.cc
@@ -24,7 +24,9 @@
#include "net/base/ip_address.h"
#include "net/base/ip_endpoint.h"
#include "net/dns/host_resolver.h"
+#include "net/dns/mock_host_resolver.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
+#include "services/network/public/cpp/features.h"
#include "services/network/public/mojom/network_context.mojom.h"
#include "services/network/public/mojom/udp_socket.mojom.h"
#include "services/network/test/test_network_context.h"
@@ -135,15 +137,16 @@
return server_socket_;
}
+ protected:
+ std::unique_ptr<test::IsolatedWebAppContentBrowserClient> client_;
+ std::unique_ptr<content::test::AsyncJsRunner> runner_;
+
private:
BrowserContext* browser_context() {
return shell()->web_contents()->GetBrowserContext();
}
mojo::Remote<network::mojom::UDPSocket> server_socket_;
-
- std::unique_ptr<test::IsolatedWebAppContentBrowserClient> client_;
- std::unique_ptr<content::test::AsyncJsRunner> runner_;
};
IN_PROC_BROWSER_TEST_F(DirectSocketsUdpBrowserTest, CloseUdp) {
@@ -619,4 +622,170 @@
::testing::StartsWith("closeUdp failed"));
}
+struct DirectSocketsMulticastBrowserTestParams {
+ bool flag_enabled;
+ bool has_permission_policy;
+ bool connected_else_bound_socket;
+ bool use_hostname;
+};
+
+class DirectSocketsMulticastBrowserTest
+ : public DirectSocketsUdpBrowserTest,
+ public testing::WithParamInterface<
+ DirectSocketsMulticastBrowserTestParams> {
+ public:
+#if BUILDFLAG(IS_CHROMEOS)
+ DirectSocketsMulticastBrowserTest() {
+ chromeos::PermissionBrokerClient::InitializeFake();
+ FirewallHoleDelegate::SetAlwaysOpenFirewallHoleForTesting(true);
+ }
+
+ ~DirectSocketsMulticastBrowserTest() override {
+ chromeos::PermissionBrokerClient::Shutdown();
+ FirewallHoleDelegate::SetAlwaysOpenFirewallHoleForTesting(false);
+ }
+#endif // BUILDFLAG(IS_CHROMEOS)
+
+ void SetUpInProcessBrowserTestFixture() override {
+ DirectSocketsUdpBrowserTest::SetUpInProcessBrowserTestFixture();
+ if (GetParam().flag_enabled) {
+ feature_list_.InitAndEnableFeature(
+ network::features::
+ kDirectSocketsUdpSendRequireMulticastPermissionPolicy);
+ } else {
+ feature_list_.InitAndDisableFeature(
+ network::features::
+ kDirectSocketsUdpSendRequireMulticastPermissionPolicy);
+ }
+ }
+
+ protected:
+ void SetUpOnMainThread() override {
+ ContentBrowserTest::SetUpOnMainThread();
+
+ // The mock DNS rule must be added before `NavigateToURL` is called.
+ // Calling `NavigateToURL` initiates host resolution, which locks the
+ // resolver and prevents subsequent modifications (triggering a check
+ // failure).
+ host_resolver()->AddRule("mcast.test", "224.0.0.251");
+
+ client_ = CreateContentBrowserClient();
+ runner_ =
+ std::make_unique<content::test::AsyncJsRunner>(shell()->web_contents());
+
+ ASSERT_TRUE(NavigateToURL(shell(), GetTestPageURL()));
+ }
+
+ std::unique_ptr<test::IsolatedWebAppContentBrowserClient>
+ CreateContentBrowserClient() override {
+ return std::make_unique<test::IsolatedWebAppContentBrowserClient>(
+ url::Origin::Create(GetTestPageURL()));
+ }
+
+ GURL GetTestPageURL() override {
+ return test::FileWithHeaders("/direct_sockets/udp.html")
+ .WithCOIHeaders()
+ .WithPermissionsPolicy("cross-origin-isolated", "(self)")
+ .WithPermissionsPolicy("direct-sockets", "(self)")
+ .WithPermissionsPolicy("local-network", "(self)")
+ .WithPermissionsPolicy("loopback-network", "(self)")
+ .WithPermissionsPolicy(
+ "direct-sockets-multicast",
+ GetParam().has_permission_policy ? "(self)" : "()")
+ .Build(embedded_test_server());
+ }
+
+ private:
+ base::test::ScopedFeatureList feature_list_;
+};
+
+INSTANTIATE_TEST_SUITE_P(
+ All,
+ DirectSocketsMulticastBrowserTest,
+ testing::Values(
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/false, /*has_permission_policy=*/false,
+ /*connected_else_bound_socket=*/false, /*use_hostname=*/false},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/false, /*has_permission_policy=*/false,
+ /*connected_else_bound_socket=*/false, /*use_hostname=*/true},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/false, /*has_permission_policy=*/false,
+ /*connected_else_bound_socket=*/true, /*use_hostname=*/false},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/false, /*has_permission_policy=*/false,
+ /*connected_else_bound_socket=*/true, /*use_hostname=*/true},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/false, /*has_permission_policy=*/true,
+ /*connected_else_bound_socket=*/false, /*use_hostname=*/false},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/false, /*has_permission_policy=*/true,
+ /*connected_else_bound_socket=*/false, /*use_hostname=*/true},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/false, /*has_permission_policy=*/true,
+ /*connected_else_bound_socket=*/true, /*use_hostname=*/false},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/false, /*has_permission_policy=*/true,
+ /*connected_else_bound_socket=*/true, /*use_hostname=*/true},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/true, /*has_permission_policy=*/false,
+ /*connected_else_bound_socket=*/false, /*use_hostname=*/false},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/true, /*has_permission_policy=*/false,
+ /*connected_else_bound_socket=*/false, /*use_hostname=*/true},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/true, /*has_permission_policy=*/false,
+ /*connected_else_bound_socket=*/true, /*use_hostname=*/false},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/true, /*has_permission_policy=*/false,
+ /*connected_else_bound_socket=*/true, /*use_hostname=*/true},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/true, /*has_permission_policy=*/true,
+ /*connected_else_bound_socket=*/false, /*use_hostname=*/false},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/true, /*has_permission_policy=*/true,
+ /*connected_else_bound_socket=*/false, /*use_hostname=*/true},
+ DirectSocketsMulticastBrowserTestParams{
+ /*flag_enabled=*/true, /*has_permission_policy=*/true,
+ /*connected_else_bound_socket=*/true, /*use_hostname=*/false},
... (truncated)
Original Bug Report
Bypass of multicast permission policy in RestrictedUDPSocket::SendTo
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The RestrictedUDPSocket class fails to check the allow_multicast_ permission flag during outgoing SendTo operations. This allows a document or context (such as an Isolated Web App) lacking the direct-sockets-multicast Permissions Policy to transmit arbitrary UDP datagrams to local network multicast groups. This bypass is possible using both direct IP literals and hostnames resolved via DNS.
Affected files:
services/network/restricted_udp_socket.ccservices/network/restricted_udp_socket.h
Estimated timestamp from git blame: 2025-09-15
Root Cause Analysis
In services/network/restricted_udp_socket.cc, the RestrictedUDPSocket class handles Direct Sockets UDP capabilities on a per-socket basis, tracking whether multicast operations are permitted via the allow_multicast_ member. While this flag is checked during JoinGroup and LeaveGroup Mojo calls, it is ignored on outgoing SendTo operations.
Specifically, in the raw IP literal path of RestrictedUDPSocket::SendTo:
if (net::IPAddress address; address.AssignFromIPLiteral(dest_addr.host())) {
udp_socket_->SendTo(net::IPEndPoint(std::move(address), dest_addr.port()),
data, traffic_annotation_, std::move(callback));
return;
}
And in the DNS resolution path within RestrictedUDPSocket::OnResolveCompleteForSendTo:
void RestrictedUDPSocket::OnResolveCompleteForSendTo(
std::vector<uint8_t> data,
SendToCallback callback,
int result,
const net::ResolveErrorInfo&,
const net::AddressList& resolved_addresses,
const net::HostResolverEndpointResults&) {
if (result != net::OK) {
std::move(callback).Run(result);
return;
}
udp_socket_->SendTo(resolved_addresses.front(), std::move(data),
traffic_annotation_, std::move(callback));
}
Neither path verifies if the target IP address is a multicast address when allow_multicast_ is set to false.
Potential Exploit Scenario
An attacker operating from a context that has direct socket access (such as an Isolated Web App or extension) but lacks the direct-sockets-multicast Permissions Policy could potentially execute the following steps:
- Open a bound UDP socket using
navigator.directSockets.openBoundUDPSocket({ localAddress: '0.0.0.0' }). - The browser validates the request and determines that multicast is not allowed for this context, passing
allow_multicast = falseto the Network Service. The Network Service instantiates aRestrictedUDPSocketwithallow_multicast_ = false. - The page triggers a UDP
SendTorequest targeting either a multicast IP literal (e.g.,224.0.0.251orff02::fbon port5353for mDNS) or a domain name they control that resolves via DNS to a multicast IP address. - Because
RestrictedUDPSocketdoes not checkallow_multicast_in its send paths, the network process receives the destination and forwards the raw UDP payload onto the local network link.
This would permit active local network discovery and spoofing (e.g., mDNS or SSDP spoofing) to discover local services on the user’s LAN, despite the permissions policy denying multicast access.
Note: Since our testing tools cannot currently execute code or launch real browsers, these steps are based on a static code review and are potential/suggested steps rather than a run proof-of-concept.
Suggested Fix
Ensure that both the fast-path direct IP destination and the DNS-resolved destinations are checked against the allow_multicast_ bit before transmitting. If a multicast address is detected but allow_multicast_ is false, terminate the connection and return a net error:
In RestrictedUDPSocket::SendTo:
if (net::IPAddress address; address.AssignFromIPLiteral(dest_addr.host())) {
if (address.IsMulticast() && !allow_multicast_) {
std::move(callback).Run(net::ERR_ACCESS_DENIED);
mojo::ReportBadMessage("no permission to use multicast");
return;
}
udp_socket_->SendTo(net::IPEndPoint(std::move(address), dest_addr.port()),
data, traffic_annotation_, std::move(callback));
return;
}
In RestrictedUDPSocket::OnResolveCompleteForSendTo:
if (resolved_addresses.front().address().IsMulticast() && !allow_multicast_) {
std::move(callback).Run(net::ERR_ACCESS_DENIED);
mojo::ReportBadMessage("no permission to use multicast");
return;
}
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
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.