Low watch CVSS 6.5 chrome Authorization Error 🔧 Commit mapped

Overview

Low
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Core
DescriptionMissing authorization in Core
ComponentCore
Sub-componentContent
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.
Tracker518125889
Fix commit7e2abdeea2f2 (chromium/src) +321/-123
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-30 (per vendor advisory)
Fix landed2026-07-15 (public git)
Disclosed2026-08-25
Patch gap41 days — fix public in source before the advisory

Background

Capability delegation
a web feature where a frame that has transient user activation can attach a delegated_capability (for example blink::mojom::DelegatedCapability::kFullscreenRequest) to a postMessage, so the receiving frame can use a gesture-gated API once without having its own user activation.
`RouteMessageEvent`
the RemoteFrameHost Mojo method that a sender’s renderer calls on a RenderFrameProxyHost to send a cross-process postMessage; the browser forwards the blink::TransferableMessage, including its delegated_capability field, to the target frame’s renderer.
Transient user activation
per-frame “recent user gesture” state that the browser keeps in RenderFrameHostImpl::user_activation_state_; using the gesture consumes it, and the renderer reports that consumption to the browser with a separate asynchronous IPC.
`bad_message::ReceivedBadMessage`
the browser’s way of killing a renderer process that sends an IPC a well-behaved renderer could never send, used here with the new reason RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY.

Root Cause Analysis

Before the patch, the browser-side postMessage routing path in render_frame_proxy_host.cc (the RouteMessageEvent handler named in the commit message) forwarded message.delegated_capability to the target without checking it. The only enforcement of “delegation requires a user gesture” was in the sender’s renderer (Blink’s DOMWindow::postMessage), so a compromised renderer could set any DelegatedCapability value on a message sent without user activation. The browser trusted that field, and the target frame’s RenderFrameHostImpl::ReceivedDelegatedCapability then turned it into browser-side capability state (per its comment, capabilities “checked or consumed on the browser side” are activated there).

The patch looks up the sender’s source_rfh once, near the top of the function. If the message carries a capability other than kNone, it kills the sender process when there is no source_frame_token, silently drops the message when the source frame is gone, and kills the process unless source_rfh->IsActiveUserActivation() is true or last_user_activation_consumed_time() is less than kMaxDelegationDelay (1 second) ago. To make that last check possible, ConsumeTransientUserActivation now records last_user_activation_consumed_time_ whenever ConsumeIfActive() succeeds, and the timestamp is reset when activation is cleared. ReceivedDelegatedCapability also now returns early unless the target RFH is in LifecycleStateImpl::kActive.

Key insight
The browser accepted a security-relevant, renderer-supplied field (delegated_capability) and never checked it against the sender’s user activation, which the browser itself tracks. The fix adds that check in RouteMessageEvent and treats a delegation without activation as a bad IPC.

Attack Path

  1. Compromise a renderer The attacker first needs code execution in a renderer process, for example through a separate Blink or V8 bug. Ordinary web content cannot set delegated_capability without a gesture, because Blink enforces that rule in the renderer.
  2. Forge the IPC The compromised renderer sends RouteMessageEvent to a cross-process frame’s RenderFrameProxyHost with message.delegated_capability set (the regression test injects kFullscreenRequest), while the sending frame has no transient user activation.
  3. Browser forwards unchecked Before the patch, the browser routed the message and the capability to the target frame, and ReceivedDelegatedCapability activated browser-side state for it.
  4. Gesture-gated API used without a gesture The receiving frame can then use the delegated capability (for example, request fullscreen) even though no user ever interacted with it. Whether other capability types (payment request, display capture) were usable this way is (inferred).

Impact Assessment

An attacker who already controls a renderer can bypass the browser’s user-activation requirement for capability delegation, so it can start gesture-gated actions such as fullscreen in itself or in a cooperating frame in another process without any user interaction. That enables UI spoofing or nuisance behaviour (inferred). It does not give memory corruption or a sandbox escape. The required compromised renderer is shown by the regression test, which uses a Mojo interceptor rather than plain JavaScript. The advisory classifies the bug only as “Missing authorization in Core” and states no in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/render_frame_proxy_host.cc
modified

Files Changed

  • content/browser/renderer_host/render_frame_host_impl.h
  • content/browser/renderer_host/render_frame_proxy_host.cc
  • tools/metrics/histograms/metadata/stability/enums.xml
  • content/browser/bad_message.h
  • content/browser/renderer_host/render_frame_proxy_host.h
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/security_exploit_browsertest.cc

Audit Directions

  • Renderer-supplied gesture or capability flags
    Look for other Mojo messages where the renderer asserts that a user gesture or permission exists (has_user_gesture, user_activation, capability enums) and the browser forwards or acts on it without checking RenderFrameHostImpl user-activation state.
  • Time-window authorisation
    The 1-second kMaxDelegationDelay grace period lets one consumed activation authorise more than one delegated message within that window. Check whether a consumed activation can be reused for several delegations, or by a different frame in the same process.
  • Lifecycle gating on browser-side state changes
    Like the new kActive check in ReceivedDelegatedCapability, audit other handlers that change per-RFH security state (activation, permissions, focus) for missing checks on back-forward-cached, prerendered or pending-deletion frames.
From 7e2abdeea2f28abae974d06547f044c39236079a Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
Date: Wed, 15 Jul 2026 16:14:32 -0700
Subject: [PATCH] [agy][content] Verify sender gesture for delegated capabilities

Add browser-side validation in RouteMessageEvent to ensure the sender
frame had user activation when a postMessage delegates a capability.

To support asynchronous consumption of user activation (where WPT tests
synchronously consume the gesture inside DOMWindow::postMessage in the
renderer prior to message routing, sending an asynchronous IPC to
consume it in the browser), RenderFrameHostImpl tracks the timestamp
when its transient user activation was last consumed. RouteMessageEvent
allows delegation if the sender frame has active user activation or has
consumed it within a short grace period.

Fixed: 518125889
Test: SecurityExploitBrowserTest.PostMessageDelegatedCapabilityWithoutUserActivation
Change-Id: Id36fdbbcf637676abfcecfba1b31067cfeba1e8f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7994936
Reviewed-by: Caitlin Fischer <caitlinfischer@google.com>
Reviewed-by: Avi Drissman <avi@chromium.org>
Commit-Queue: Tzarial <zork@chromium.org>
Reviewed-by: Ken Buchanan <kenrb@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1662927}
---
diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h
index c943914..4d456ba 100644
--- a/content/browser/bad_message.h
+++ b/content/browser/bad_message.h
@@ -384,6 +384,7 @@
   BIBI_BIND_GAMEPAD_MONITOR_BLOCKED_BY_PERMISSIONS_POLICY = 356,
   BIBI_BIND_GAMEPAD_HAPTICS_MANAGER_BLOCKED_BY_PERMISSIONS_POLICY = 357,
   RFH_CREATE_NEW_WINDOW_INVALID_PIP_OPTIONS = 358,
+  RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY = 359,
 
   // Please add new elements here. The naming convention is abbreviated class
   // name (e.g. RenderFrameHost becomes RFH) plus a unique description of the
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 268b4e8..e913d65 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -7667,10 +7667,13 @@
   user_activation_state_.Clear();
   history_user_activation_state_.Clear();
   GetMainFrame()->honor_sticky_activation_for_history_intervention_ = true;
+  last_user_activation_consumed_time_ = base::TimeTicks();
 }
 
 void RenderFrameHostImpl::ConsumeTransientUserActivation() {
-  user_activation_state_.ConsumeIfActive();
+  if (user_activation_state_.ConsumeIfActive()) {
+    last_user_activation_consumed_time_ = base::TimeTicks::Now();
+  }
 }
 
 void RenderFrameHostImpl::ActivateUserActivation(
@@ -11391,6 +11394,9 @@
 
 void RenderFrameHostImpl::ReceivedDelegatedCapability(
     blink::mojom::DelegatedCapability delegated_capability) {
+  if (lifecycle_state() != LifecycleStateImpl::kActive) {
+    return;
+  }
   // Every delegated capability that is checked or consumed on the browser side
   // needs to be (a) activated here and (b) consumed when RFH handles the
   // capability.
diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h
index 846a852..11f5d8fc 100644
--- a/content/browser/renderer_host/render_frame_host_impl.h
+++ b/content/browser/renderer_host/render_frame_host_impl.h
@@ -3145,6 +3145,9 @@
   // Returns the sticky bit of the User Activation v2 state of this document.
   bool HasStickyUserActivation() const;
   bool IsActiveUserActivation() const;
+  base::TimeTicks last_user_activation_consumed_time() const {
+    return last_user_activation_consumed_time_;
+  }
   void ClearUserActivation();
   void ConsumeTransientUserActivation();
   void ActivateUserActivation(
@@ -5470,6 +5473,9 @@
   // details on how this state is maintained.
   blink::UserActivationState user_activation_state_;
 
+  // The timestamp when the transient user activation was last consumed.
+  base::TimeTicks last_user_activation_consumed_time_;
+
   // Similar to `user_activation_state_`, but specifically for use with
   // web-exposed history manipulation (e.g., cancelling a history navigation via
   // the Navigation API). Activated when `user_activation_state_` is activated,
diff --git a/content/browser/renderer_host/render_frame_proxy_host.cc b/content/browser/renderer_host/render_frame_proxy_host.cc
index 3d13490..feb76d9 100644
--- a/content/browser/renderer_host/render_frame_proxy_host.cc
+++ b/content/browser/renderer_host/render_frame_proxy_host.cc
@@ -16,6 +16,7 @@
 #include "base/metrics/histogram_functions.h"
 #include "base/no_destructor.h"
 #include "base/strings/utf_string_conversions.h"
+#include "base/time/time.h"
 #include "base/timer/elapsed_timer.h"
 #include "base/trace_event/typed_macros.h"
 #include "base/types/optional_util.h"
@@ -503,6 +504,12 @@
     blink::TransferableMessage message) {
   RenderFrameHostImpl* target_rfh = frame_tree_node()->current_frame_host();
 
+  RenderFrameHostImpl* source_rfh = nullptr;
+  if (source_frame_token) {
+    source_rfh = RenderFrameHostImpl::FromFrameToken(
+        GetProcess()->GetID(), source_frame_token.value());
+  }
+
   // Give the embedder a chance to override the target for this postMessage.
   RenderFrameHost* override_target =
       GetContentClient()->browser()->GetPostMessageTargetOverride(
@@ -544,6 +551,43 @@
     return;
   }
 
+  if (message.delegated_capability !=
+      blink::mojom::DelegatedCapability::kNone) {
+    if (!source_frame_token) {
+      bad_message::ReceivedBadMessage(
+          GetProcess(),
+          bad_message::RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY);
+      return;
+    }
+    if (!source_rfh) {
+      // If the source frame was detached while the message was in flight,
+      // silently drop the message. Do not kill the renderer process because
+      // this is a benign race condition.
+      return;
+    }
+
+    // Check if the source frame currently has transient user activation, or if
+    // it consumed transient user activation recently. Legitimate delegation
+    // consumes user activation in the renderer process synchronously, which
+    // asynchronously propagates to the browser via an IPC on the LocalFrameHost
+    // Mojo interface. To prevent race conditions where the consumption IPC is
+    // processed before RouteMessageEvent, we allow delegation if transient user
+    // activation was active or consumed recently.
+    static constexpr base::TimeDelta kMaxDelegationDelay = base::Seconds(1);
+    bool has_activation =
+        source_rfh->IsActiveUserActivation() ||
+        (!source_rfh->last_user_activation_consumed_time().is_null() &&
+         base::TimeTicks::Now() -
+                 source_rfh->last_user_activation_consumed_time() <
+             kMaxDelegationDelay);
+    if (!has_activation) {
+      bad_message::ReceivedBadMessage(
+          GetProcess(),
+          bad_message::RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY);
+      return;
+    }
+  }
+
   SiteInstanceGroup* target_group = target_rfh->GetSiteInstance()->group();
 
   bool is_embedder_to_guest_communication = false;
@@ -563,21 +607,17 @@
   }
 
   bool is_guest_to_embedder_communication = false;
-  if (source_frame_token) {
-    RenderFrameHostImpl* source_rfh = RenderFrameHostImpl::FromFrameToken(
-        GetProcess()->GetID(), source_frame_token.value());
-    if (source_rfh) {
-      RenderFrameHostImpl* source_outermost_rfh =
-          source_rfh->GetOutermostMainFrame();
-      RenderFrameHostImpl* source_embedder_rfh =
-          source_outermost_rfh->GetParentOrOuterDocumentOrEmbedder();
-      // Note that this is not checking that the source and target are related,
-      // but that the target is related to the embedder.
-      if (source_embedder_rfh &&
-          target_group->IsRelatedSiteInstanceGroup(
-              source_embedder_rfh->GetSiteInstance()->group())) {
-        is_guest_to_embedder_communication = true;
-      }
+  if (source_rfh) {
+    RenderFrameHostImpl* source_outermost_rfh =
+        source_rfh->GetOutermostMainFrame();
+    RenderFrameHostImpl* source_embedder_rfh =
+        source_outermost_rfh->GetParentOrOuterDocumentOrEmbedder();
+    // Note that this is not checking that the source and target are related,
+    // but that the target is related to the embedder.
+    if (source_embedder_rfh &&
+        target_group->IsRelatedSiteInstanceGroup(
+            source_embedder_rfh->GetSiteInstance()->group())) {
+      is_guest_to_embedder_communication = true;
     }
   }
 
@@ -602,116 +642,110 @@
   // If there is a |source_frame_token|, translate it to the frame token of the
   // equivalent RenderFrameProxyHost in the target process.
   std::optional<blink::RemoteFrameToken> translated_source_token;
-  if (source_frame_token) {
-    RenderFrameHostImpl* source_rfh = RenderFrameHostImpl::FromFrameToken(
-        GetProcess()->GetID(), source_frame_token.value());
-    if (source_rfh) {
-      // https://crbug.com/822958: If the postMessage is going to a descendant
-      // frame, ensure that any pending visual properties such as size are sent
-      // to the target frame before the postMessage, as sites might implicitly
-      // be relying on this ordering.
-      bool target_is_descendant_of_source = false;
-      for (RenderFrameHost* rfh = target_rfh; rfh; rfh = rfh->GetParent()) {
-        if (rfh == source_rfh) {
-          target_is_descendant_of_source = true;
-          break;
+  if (source_rfh) {
+    // https://crbug.com/822958: If the postMessage is going to a descendant
+    // frame, ensure that any pending visual properties such as size are sent
+    // to the target frame before the postMessage, as sites might implicitly
+    // be relying on this ordering.
+    bool target_is_descendant_of_source = false;
+    for (RenderFrameHost* rfh = target_rfh; rfh; rfh = rfh->GetParent()) {
+      if (rfh == source_rfh) {
+        target_is_descendant_of_source = true;
+        break;
+      }
+    }
+    if (target_is_descendant_of_source) {
+      target_rfh->GetRenderWidgetHost()
+          ->SynchronizeVisualPropertiesIgnoringPendingAck();
+    }
+
+    if (was_overridden) {
+      // If the target_rfh was overridden, skip creating a
+      // RenderFrameProxyHost because a persistent communication channel
+      // shouldn't be persisted after the message is sent.
+    } else if (is_embedder_to_guest_communication) {
+      // We create a RenderFrameProxyHost for the embedder in the guest's
+      // render process but we intentionally do not expose the embedder's
+      // opener chain to it.
+      // TODO(crbug.com/40261772): Using the main frame will lead to a null
+      // event.source if a subframe posts a message to the guest. See also
+      // https://crbug.com/41172969
+      CHECK(target_rfh->is_main_frame());
+      source_rfh->GetMainFrame()
+          ->frame_tree_node()
+          ->render_manager()
+          ->CreateRenderFrameProxy(
+              target_rfh->GetSiteInstance()->group(),
+              source_rfh->GetMainFrame()->browsing_context_state(),
+              /*navigation_metrics_token=*/std::nullopt);
+    } else if (is_guest_to_embedder_communication) {
+      // A RenderFrameProxyHost was already created when the guest was
+      // attached.
+      // We do not create proxies for the subframes of a guest. Note that the
+      // computation of `is_embedder_to_guest_communication` above assumes
+      // that guest subframes are not targetable.
+    } else {
+      // Ensure that we have a proxy for the source frame in the target
+      // SiteInstance. If it doesn't exist, create it on demand and also
+      // create its opener chain, since that will also be accessible to the
+      // target page. This may be needed in rare cases such as a popup sending
+      // a postMessage to a subframe of its opener, where the subframe has no
+      // prior references to the popup.
+      //
+      // All cases where a proxy might be missing must involve a message being
+      // sent across different FrameTrees, since all frames within the same
+      // FrameTree always have references to one another; therefore, only run
+      // proxy creation code for those cases as an optimization. For inner
+      // frame trees, guests are already handled above, and fenced frames
+      // disallow postMessage to/from their embedder.
+      if (&source_rfh->frame_tree_node()->frame_tree() !=
+          &target_rfh->frame_tree_node()->frame_tree()) {
+        // Subtle: postMessages may be sent between frames after their page
+        // has entered the back-forward cache (e.g., when dispatched from
+        // pagehide events) - see
+        // BackForwardCacheBrowserTest.PostMessageDelivered. (These messages
+        // are subsequently deferred by the target renderer until the cached
+        // page is re-activated.) In that case, it's neither correct nor
+        // possible to create proxies, as that requires going through
+        // FrameTreeNode and RenderFrameHostManager, where the current
+        // RenderFrameHost is no longer `source_rfh` but rather some other RFH
+        // in an unrelated SiteInstance. Fortunately, back-forward cache
+        // restricts GetRelatedActiveContentsCount to 1, and new on-demand
+        // proxies shouldn't ever be needed within a single FrameTree, where
+        // all frames already have references to one another. So, this case
+        // can simply be skipped. The same constraint would also apply to
+        // pending deletion RenderFrameHosts where messages could be sent from
+        // unload handlers, where it's also incorrect to create proxies in a
+        // FrameTreeNode that has moved on to some other unrelated RFH.
+        if (!source_rfh->IsInBackForwardCache() &&
+            !source_rfh->IsPendingDeletion()) {
+          // After skipping back-forward cache and pending deletion cases, we
+          // should only get here when source_rfh is the current RFH in its
+          // FrameTreeNode, since we shouldn't receive messages from
+          // speculative or pending-commit RenderFrameHosts.
+          CHECK_EQ(source_rfh,
+                   source_rfh->frame_tree_node()->current_frame_host());
+          source_rfh->frame_tree_node()->render_manager()->CreateOpenerProxies(
+              target_rfh->GetSiteInstance()->group(), nullptr,
+              source_rfh->browsing_context_state(),
+              /*navigation_metrics_token=*/std::nullopt);
         }
       }
-      if (target_is_descendant_of_source) {
-        target_rfh->GetRenderWidgetHost()
-            ->SynchronizeVisualPropertiesIgnoringPendingAck();
-      }
+    }
 
-      if (was_overridden) {
-        // If the target_rfh was overridden, skip creating a
-        // RenderFrameProxyHost because a persistent communication channel
-        // shouldn't be persisted after the message is sent.
-      } else if (is_embedder_to_guest_communication) {
-        // We create a RenderFrameProxyHost for the embedder in the guest's
-        // render process but we intentionally do not expose the embedder's
-        // opener chain to it.
-        // TODO(crbug.com/40261772): Using the main frame will lead to a null
-        // event.source if a subframe posts a message to the guest. See also
-        // https://crbug.com/41172969
-        CHECK(target_rfh->is_main_frame());
-        source_rfh->GetMainFrame()
-            ->frame_tree_node()
-            ->render_manager()
-            ->CreateRenderFrameProxy(
-                target_rfh->GetSiteInstance()->group(),
-                source_rfh->GetMainFrame()->browsing_context_state(),
-                /*navigation_metrics_token=*/std::nullopt);
-      } else if (is_guest_to_embedder_communication) {
-        // A RenderFrameProxyHost was already created when the guest was
-        // attached.
-        // We do not create proxies for the subframes of a guest. Note that the
-        // computation of `is_embedder_to_guest_communication` above assumes
-        // that guest subframes are not targetable.
-      } else {
-        // Ensure that we have a proxy for the source frame in the target
-        // SiteInstance. If it doesn't exist, create it on demand and also
-        // create its opener chain, since that will also be accessible to the
-        // target page. This may be needed in rare cases such as a popup sending
-        // a postMessage to a subframe of its opener, where the subframe has no
-        // prior references to the popup.
-        //
-        // All cases where a proxy might be missing must involve a message being
-        // sent across different FrameTrees, since all frames within the same
-        // FrameTree always have references to one another; therefore, only run
-        // proxy creation code for those cases as an optimization. For inner
-        // frame trees, guests are already handled above, and fenced frames
-        // disallow postMessage to/from their embedder.
-        if (&source_rfh->frame_tree_node()->frame_tree() !=
-            &target_rfh->frame_tree_node()->frame_tree()) {
-          // Subtle: postMessages may be sent between frames after their page
-          // has entered the back-forward cache (e.g., when dispatched from
-          // pagehide events) - see
-          // BackForwardCacheBrowserTest.PostMessageDelivered. (These messages
-          // are subsequently deferred by the target renderer until the cached
-          // page is re-activated.) In that case, it's neither correct nor
-          // possible to create proxies, as that requires going through
-          // FrameTreeNode and RenderFrameHostManager, where the current
-          // RenderFrameHost is no longer `source_rfh` but rather some other RFH
-          // in an unrelated SiteInstance. Fortunately, back-forward cache
-          // restricts GetRelatedActiveContentsCount to 1, and new on-demand
-          // proxies shouldn't ever be needed within a single FrameTree, where
-          // all frames already have references to one another. So, this case
-          // can simply be skipped. The same constraint would also apply to
-          // pending deletion RenderFrameHosts where messages could be sent from
-          // unload handlers, where it's also incorrect to create proxies in a
-          // FrameTreeNode that has moved on to some other unrelated RFH.
-          if (!source_rfh->IsInBackForwardCache() &&
-              !source_rfh->IsPendingDeletion()) {
-            // After skipping back-forward cache and pending deletion cases, we
-            // should only get here when source_rfh is the current RFH in its
-            // FrameTreeNode, since we shouldn't receive messages from
-            // speculative or pending-commit RenderFrameHosts.
-            CHECK_EQ(source_rfh,
-                     source_rfh->frame_tree_node()->current_frame_host());
-            source_rfh->frame_tree_node()
-                ->render_manager()
-                ->CreateOpenerProxies(
-                    target_rfh->GetSiteInstance()->group(), nullptr,
-                    source_rfh->browsing_context_state(),
-                    /*navigation_metrics_token=*/std::nullopt);
-          }
-        }
-      }
-
-      // If the message source is a cross-process subframe, its proxy will only
-      // be created in --site-per-process mode, which is the case when we set an
-      // actual non-empty value for |translated_source_token|. Otherwise (if the
-      // proxy wasn't created), use an empty |translated_source_token| (see
-      // https://crbug.com/485520 for discussion on why this is ok).
-      // The proxy may be in a different BrowsingContextState in the case of
-      // postMessages exchanged across inner and outer delegates.
-      RenderFrameProxyHost* source_proxy_in_target_group =
-          source_rfh->browsing_context_state()->GetRenderFrameProxyHost(
-              target_group,
-              BrowsingContextState::ProxyAccessMode::kAllowOuterDelegate);
-      if (source_proxy_in_target_group) {
-        translated_source_token = source_proxy_in_target_group->GetFrameToken();
-      }
+    // If the message source is a cross-process subframe, its proxy will only
+    // be created in --site-per-process mode, which is the case when we set an
+    // actual non-empty value for |translated_source_token|. Otherwise (if the
+    // proxy wasn't created), use an empty |translated_source_token| (see
+    // https://crbug.com/485520 for discussion on why this is ok).
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index e71bf32..0fd93412 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1800,6 +1800,48 @@
       swapped_impl_;
 };
 
+class CapabilityDelegationRemoteFrameHostInterceptor
+    : public blink::mojom::RemoteFrameHostInterceptorForTesting {
+ public:
+  explicit CapabilityDelegationRemoteFrameHostInterceptor(
+      RenderFrameProxyHost* render_frame_proxy_host)
+      : swapped_impl_(
+            render_frame_proxy_host->frame_host_receiver_for_testing(),
+            this) {}
+
+  ~CapabilityDelegationRemoteFrameHostInterceptor() override = default;
+
+  RemoteFrameHost* GetForwardingInterface() override {
+    return swapped_impl_.old_impl();
+  }
+
+  void RouteMessageEvent(
+      const std::optional<blink::LocalFrameToken>& source_frame_token,
+      const url::Origin& source_origin,
+      const std::optional<url::Origin>& target_origin,
+      blink::TransferableMessage message) override {
+    // Inject the delegated capability into the message before forwarding.
+    message.delegated_capability =
+        blink::mojom::DelegatedCapability::kFullscreenRequest;
+    GetForwardingInterface()->RouteMessageEvent(
+        std::move(source_frame_token), source_origin, std::move(target_origin),
+        std::move(message));
+  }
+
+  void OpenURL(blink::mojom::OpenURLParamsPtr params) override {
+    intercepted_params_ = std::move(params);
+  }
+
+  blink::mojom::OpenURLParamsPtr GetInterceptedParams() {
+    return std::move(intercepted_params_);
+  }
+
+ private:
+  blink::mojom::OpenURLParamsPtr intercepted_params_;
+  mojo::test::ScopedSwapImplForTesting<blink::mojom::RemoteFrameHost>
+      swapped_impl_;
+};
+
 // Test verifying that a compromised renderer can't lie about the source_origin
 // passed along with the RouteMessageEvent() mojo message.  See also
 // https://crbug.com/915721.
@@ -2044,6 +2086,113 @@
             kill_waiter.Wait());
 }
 
+// Test verifying that a compromised renderer cannot delegate a capability
+// (e.g. fullscreen) without transient user activation.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       PostMessageDelegatedCapabilityWithoutUserActivation) {
+  // Explicitly isolating b.com helps ensure that this test is applicable on
+  // platforms without site-per-process.
+  IsolateOrigin("b.com");
+
+  GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* main_frame = root->current_frame_host();
+
+  // Create cross-site child frame.
+  GURL child_url(embedded_test_server()->GetURL("b.com", "/title1.html"));
+  {
+    std::string js_str = base::StringPrintf(
+        "var frame = document.createElement('iframe'); "
+        "frame.src = '%s'; "
+        "document.body.appendChild(frame);",
+        child_url.spec().c_str());
+    EXPECT_TRUE(ExecJs(main_frame, js_str));
+    ASSERT_TRUE(WaitForLoadStop(web_contents));
+  }
+
+  // main frame and subframe should be in separate processes.
+  FrameTreeNode* subframe_node = root->child_at(0);
+  RenderFrameHostImpl* subframe = subframe_node->current_frame_host();
+  EXPECT_NE(main_frame->GetProcess(), subframe->GetProcess());
+
+  // Retrieve the RenderFrameProxyHost representing the main frame for the
+  // subframe's process.
+  RenderFrameProxyHost* main_frame_proxy_host =
+      main_frame->browsing_context_state()->GetRenderFrameProxyHost(
+          subframe->GetSiteInstance()->group());
+
+  // Intercept the RouteMessageEvent IPC message that will come from
+  // the subframe process.
+  CapabilityDelegationRemoteFrameHostInterceptor mojo_interceptor(
+      main_frame_proxy_host);
+
+  // Post a message from the subframe to the cross-site parent and intercept the
+  // associated IPC message, changing it to include a delegated capability.
+  // Since there is no active or recently consumed user activation on the
+  // subframe, this should result in a renderer kill.
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(subframe->GetProcess());
+  EXPECT_TRUE(ExecJs(subframe, "parent.postMessage('blah', '*')",
+                     EXECUTE_SCRIPT_NO_USER_GESTURE));
+  EXPECT_EQ(bad_message::RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY,
+            kill_waiter.Wait());
+}
+
+// Test verifying that a legitimate renderer with user activation CAN delegate a
+// capability (e.g. fullscreen) to another frame.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       PostMessageDelegatedCapabilityWithUserActivation) {
+  // Explicitly isolating b.com helps ensure that this test is applicable on
+  // platforms without site-per-process.
+  IsolateOrigin("b.com");
+
+  GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* main_frame = root->current_frame_host();
+
+  // Create cross-site child frame.
+  GURL child_url(embedded_test_server()->GetURL("b.com", "/title1.html"));
+  {
+    std::string js_str = base::StringPrintf(
+        "var frame = document.createElement('iframe'); "
+        "frame.src = '%s'; "
+        "document.body.appendChild(frame);",
+        child_url.spec().c_str());
+    EXPECT_TRUE(ExecJs(main_frame, js_str));
+    ASSERT_TRUE(WaitForLoadStop(web_contents));
+  }
+
+  // main frame and subframe should be in separate processes.
+  FrameTreeNode* subframe_node = root->child_at(0);
+  RenderFrameHostImpl* subframe = subframe_node->current_frame_host();
+  EXPECT_NE(main_frame->GetProcess(), subframe->GetProcess());
+
+  // Retrieve the RenderFrameProxyHost representing the main frame for the
+  // subframe's process.
+  RenderFrameProxyHost* main_frame_proxy_host =
+      main_frame->browsing_context_state()->GetRenderFrameProxyHost(
+          subframe->GetSiteInstance()->group());
+
+  // Intercept the RouteMessageEvent IPC message that will come from
+  // the subframe process.
+  CapabilityDelegationRemoteFrameHostInterceptor mojo_interceptor(
+      main_frame_proxy_host);
+
+  // Post a message from the subframe to the cross-site parent and intercept the
+  // associated IPC message, changing it to include a delegated capability.
+  // This time, we execute the script WITH user gesture (default ExecJs option).
+  // The browser should allow this and NOT terminate the subframe process.
+  EXPECT_TRUE(ExecJs(subframe, "parent.postMessage('blah', '*')"));
+
+  // Verify that the subframe process is still alive.
+  EXPECT_TRUE(subframe->IsRenderFrameLive());
+}
+
 IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
                        InvalidRemoteNavigationInitiator) {
   // Explicitly isolating a.com helps ensure that this test is applicable on
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.