Medium watch CVSS 6.5 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Network
DescriptionMissing authorization in Network
ComponentNetwork
Sub-componentDirect sockets
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker517608454
Fix commit405665065117 (chromium/src) +548/-25
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-28 (per vendor advisory)
Fix landed2026-07-13 (public git)
Disclosed2026-08-25
Patch gap43 days — fix public in source before the advisory

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 RestrictedUDPSocket derived from the multicast permission policy, previously enforced only on JoinGroup() and LeaveGroup().
`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.

Key insight
The single mistake was enforcing the multicast permission only on group membership (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

  1. Obtain socket access A document with Direct Sockets access but without the direct-sockets-multicast Permissions Policy opens a bound or connected UDP socket.
  2. 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.
  3. Bypass the ungated path Because RestrictedUDPSocket::SendTo/OnResolveCompleteForSendTo and DirectSocketsServiceImpl never checked IsMulticast(), the datagram or Connect() proceeds despite the missing policy.
  4. 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

An attacker page gains the ability to send UDP datagrams to (and connect toward) IP multicast addresses without holding the 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

FunctionChangeNotes
if
chrome/browser/direct_sockets/direct_sockets_apitest.cc
modified

Files Changed

  • services/network/public/cpp/features.h
  • content/browser/direct_sockets/direct_sockets_udp_browsertest.cc
  • services/network/network_context_unittest.cc
  • services/network/socket_factory.cc
  • chrome/browser/about_flags.cc
  • chrome/browser/direct_sockets/direct_sockets_apitest.cc
  • chrome/browser/flag_descriptions.h
  • chrome/browser/flag-metadata.json
  • content/test/data/direct_sockets/udp.js
  • net/base/net_error_list.h
  • third_party/blink/renderer/modules/direct_sockets/udp_socket.cc
  • tools/metrics/histograms/enums.xml
  • services/network/restricted_udp_socket.cc
  • content/browser/direct_sockets/direct_sockets_service_impl.cc
  • third_party/blink/renderer/modules/direct_sockets/udp_writable_stream_wrapper.cc
  • services/network/public/cpp/features.cc

Audit Directions

  • Partial gate coverage
    When 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 checks
    Verify 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 point
    For 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.
From 405665065117459f6a72161b865c8244742b21c7 Mon Sep 17 00:00:00 2001
From: Tsuyoshi Horo <horo@chromium.org>
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 <horo@chromium.org>
Reviewed-by: Vlad Krot <vkrot@google.com>
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": [ "fserb@chromium.org" ],
     "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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • services/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:

  1. Open a bound UDP socket using navigator.directSockets.openBoundUDPSocket({ localAddress: '0.0.0.0' }).
  2. The browser validates the request and determines that multicast is not allowed for this context, passing allow_multicast = false to the Network Service. The Network Service instantiates a RestrictedUDPSocket with allow_multicast_ = false.
  3. The page triggers a UDP SendTo request targeting either a multicast IP literal (e.g., 224.0.0.251 or ff02::fb on port 5353 for mDNS) or a domain name they control that resolves via DNS to a multicast IP address.
  4. Because RestrictedUDPSocket does not check allow_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.

View on issue tracker