Overview

Low
Severity
4.2
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactConfused deputy in Fullscreen
DescriptionConfused deputy in Fullscreen
ComponentFullscreen
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.
Tracker515426792
Fix commit4580ad5bc396 (chromium/src) +183/-3
CWECWE-441
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-05-21 (per vendor advisory)
Fix landed2026-07-30 (public git)
Disclosed2026-09-08
Patch gap40 days — fix public in source before the advisory

Background

`is_xr_overlay`
a boolean field on the renderer-supplied blink::mojom::FullscreenOptions that, when set, causes ancestor renderers to mark their Document as a WebXR DOM Overlay (applying the :xr-overlay UA stylesheet, short-circuiting LocalFrameView::PaintTree() to paint only the iframe layer, and rejecting subsequent requestFullscreen() in the parent).
`RenderFrameHostImpl::EnterFullscreen`
the browser-process handler that receives a frame’s fullscreen request and forwards its FullscreenOptions to cross-process ancestors via RemoteFrame::WillEnterFullscreen so they can style the owning <iframe>.
`SetIsXrOverlaySetup` / `HasSeenRecentXrOverlaySetup`
browser-side bookkeeping where SetIsXrOverlaySetup() records a timestamp (last_xr_overlay_setup_time_) when VRServiceImpl grants an immersive session with the DOM Overlay feature, and HasSeenRecentXrOverlaySetup() reports whether such a setup occurred within the last second.
Confused deputy
a scenario where a privileged actor (the browser process) performs an action on behalf of a less-privileged one (a renderer) using authority the caller does not actually possess.

Root Cause Analysis

Before the patch, RenderFrameHostImpl::EnterFullscreen forwarded the renderer-controlled FullscreenOptions — including is_xr_overlay — to cross-process ancestors without checking whether the requesting frame had actually been granted a WebXR DOM Overlay session. Because is_xr_overlay is fully attacker-controlled in the renderer, any frame could set it to true and coerce its cross-process ancestor into XR overlay mode (applying the :xr-overlay stylesheet, painting only the iframe layer, and blocking the parent’s own requestFullscreen()), violating the invariant that XR overlay mode is entered only for genuine WebXR sessions the browser configured.

The fix ADDS a validation branch in EnterFullscreen: when options->is_xr_overlay is set, it calls HasSeenRecentXrOverlaySetup(), logs the result to the XR.DOMOverlay.IsXrOverlayFullscreenValid histogram, and resets options->is_xr_overlay = false when the browser has no recent record of an overlay setup. A secondary correctness fix rewrites HasSeenRecentXrOverlaySetup() to iterate the frame subtree via ForEachRenderFrameHostImpl, because the setup timestamp is recorded on the child OOPIF’s RenderFrameHost while an ancestor entering fullscreen on the child’s behalf would otherwise only inspect its own last_xr_overlay_setup_time_.

The fix works by making the browser cross-check the renderer’s claim against its own authoritative state before honoring it, so a forged flag is silently dropped.

Key insight
The single core mistake was trusting a renderer-supplied is_xr_overlay flag and forwarding it to ancestor frames without verifying against browser-authoritative WebXR state; the fix closes this by gating the flag on HasSeenRecentXrOverlaySetup() and clearing it when no genuine setup is recorded.

Attack Path

  1. Embed a cross-process subframe An attacker page (a.com) embeds a cross-origin iframe (b.com, site-isolated) with allowFullscreen, as reconstructed by the EnterFullscreenIgnoresXrOverlayFlagWithoutXrSetup regression test.
  2. Forge the overlay flag The subframe’s renderer constructs FullscreenOptions with is_xr_overlay = true despite no WebXR DOM Overlay session ever being granted, then invokes EnterFullscreen (backed by transient user activation).
  3. Browser forwards the unvalidated flag Pre-patch, RenderFrameHostImpl::EnterFullscreen forwarded the options to the cross-process ancestor via RemoteFrame::WillEnterFullscreen without checking HasSeenRecentXrOverlaySetup(), so the ancestor renderer marked its Document as an XR DOM Overlay.
  4. Parent enters XR overlay mode The ancestor applies the :xr-overlay UA stylesheet, short-circuits LocalFrameView::PaintTree() to paint only the iframe’s layer, and rejects the parent’s own subsequent requestFullscreen() calls, confirmed by the test asserting the parent iframe matches :xr-overlay in the vulnerable state.
  5. Downstream abuse (inferred) — Forcing the parent into overlay mode could be leveraged for UI redress / content-hiding or fullscreen denial-of-service against the top frame, but the diff itself demonstrates only the unauthorized state transition, not a concrete escalation.

Impact Assessment

An attacker controlling a cross-origin subframe can force its cross-process ancestor Document into WebXR DOM Overlay mode without any genuine WebXR session, causing the ancestor to paint only the iframe’s layer and to reject its own fullscreen requests — a confused-deputy authorization error where the browser acts on a forged renderer flag. The precondition is an embedded cross-origin (site-isolated) iframe with fullscreen permission and transient user activation, exploitable entirely from the renderer via the EnterFullscreen Mojo path. Any UI-spoofing, content-hiding, or denial-of-service beyond the unauthorized overlay-mode transition is (inferred); the advisory reports the class as AuthorizationError (confused deputy in Fullscreen) and states no exploited-in-the-wild status or memory-corruption escalation.

Changed Functions

FunctionChangeNotes
ForEachRenderFrameHostImpl
content/browser/renderer_host/render_frame_host_impl.cc
modified
if
content/browser/renderer_host/render_frame_host_impl.cc
modified
if
third_party/blink/web_tests/wpt_internal/webxr/ar/iframe-oopif.sub.https.html
modified

Files Changed

  • third_party/blink/web_tests/wpt_internal/webxr/ar/iframe-oopif.sub.https.html
  • tools/metrics/histograms/metadata/xr/histograms.xml
  • content/web_test/common/web_test.mojom
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/web_test/browser/web_test_control_host.cc
  • content/web_test/browser/web_test_control_host.h
  • content/browser/security_exploit_browsertest.cc

Audit Directions

  • Renderer-supplied option structs forwarded to other processes
    Audit other fields of FullscreenOptions and similar Mojo structs (e.g. any flag reforwarded via RemoteFrame::WillEnterFullscreen to ancestors) for the same missing browser-side authorization check.
  • Browser state keyed to the wrong frame
    Review browser-authoritative checks in OOPIF scenarios where a timestamp/flag is recorded on one RenderFrameHost but consulted on an ancestor’s, since the pre-fix single-frame HasSeenRecentXrOverlaySetup() shows this frame-mismatch is easy to introduce.
  • Feature flags gated on activation but not on capability
    Look for renderer requests that consume transient user activation as their only gate, and verify the browser independently confirms the underlying capability (here, a granted WebXR DOM Overlay session) rather than trusting the renderer’s assertion.
From 4580ad5bc3968c52393b7877797282471885c00e Mon Sep 17 00:00:00 2001
From: Alexander Cooper <alcooper@chromium.org>
Date: Thu, 30 Jul 2026 13:54:21 -0700
Subject: [PATCH] Validate is_xr_overlay in RenderFrameHostImpl::EnterFullscreen

RenderFrameHostImpl::EnterFullscreen() forwards the renderer-supplied
FullscreenOptions to cross-process ancestors via
RemoteFrame::WillEnterFullscreen so they can apply fullscreen styling to
the owning <iframe>. The is_xr_overlay flag in those options causes
ancestor renderers to mark their Document as an XR DOM Overlay, which
applies the :xr-overlay UA stylesheet, short-circuits
LocalFrameView::PaintTree() to paint only the iframe layer, and rejects
subsequent requestFullscreen() calls in the parent.

The browser already tracks whether an XR overlay session was set up for
the requesting frame via SetIsXrOverlaySetup() (called by VRServiceImpl
after a granted immersive session with the DOM Overlay feature). Use
HasSeenRecentXrOverlaySetup() to clear is_xr_overlay before forwarding
the options unless the browser itself recently configured such a
session, so cross-process ancestors only enter XR overlay mode for
genuine WebXR sessions.

Add browser tests covering both the negative case (flag is dropped when
no XR setup was recorded) and the positive case (flag is preserved after
SetIsXrOverlaySetup()).

TAG=agy

Fixed: 515426792
Change-Id: I7242948973326c3d45a51f3dd3bc00572c25265b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8157145
Reviewed-by: Charlie Reis <creis@chromium.org>
Auto-Submit: Alexander Cooper <alcooper@chromium.org>
Reviewed-by: Fred Shih <ffred@chromium.org>
Commit-Queue: Fred Shih <ffred@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1671392}
---
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 38e2a7ee..452b885 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -9357,9 +9357,19 @@
 
 bool RenderFrameHostImpl::HasSeenRecentXrOverlaySetup() {
   static constexpr base::TimeDelta kMaxInterval = base::Seconds(1);
-  base::TimeDelta delta = base::TimeTicks::Now() - last_xr_overlay_setup_time_;
-  DVLOG(2) << __func__ << ": return " << (delta <= kMaxInterval);
-  return delta <= kMaxInterval;
+  bool found_recent_setup = false;
+  // Iterate the frame subtree because an ancestor frame entering fullscreen on
+  // behalf of a child OOPIF will check its own RenderFrameHostImpl, but the XR
+  // overlay setup timestamp was recorded on the child OOPIF's RenderFrameHost.
+  ForEachRenderFrameHostImpl([&found_recent_setup](RenderFrameHostImpl* rfh) {
+    base::TimeDelta delta =
+        base::TimeTicks::Now() - rfh->last_xr_overlay_setup_time_;
+    if (delta <= kMaxInterval) {
+      found_recent_setup = true;
+    }
+  });
+  DVLOG(2) << __func__ << ": return " << found_recent_setup;
+  return found_recent_setup;
 }
 
 void RenderFrameHostImpl::SetIsXrOverlaySetup() {
@@ -9372,6 +9382,16 @@
     EnterFullscreenCallback callback) {
   const bool had_fullscreen_token = fullscreen_request_token_.IsActive();
 
+  // Validate the is_xr_overlay flag against browser-authoritative state.
+  if (options->is_xr_overlay) {
+    const bool is_valid = HasSeenRecentXrOverlaySetup();
+    base::UmaHistogramBoolean("XR.DOMOverlay.IsXrOverlayFullscreenValid",
+                              is_valid);
+    if (!is_valid) {
+      options->is_xr_overlay = false;
+    }
+  }
+
   // Frames (possibly a subframe) that are not active nor belonging to a primary
   // page should not enter fullscreen.
   if (!IsActive() || !GetPage().IsPrimary()) {
diff --git a/content/web_test/browser/web_test_control_host.cc b/content/web_test/browser/web_test_control_host.cc
index 093549b..11536ac 100644
--- a/content/web_test/browser/web_test_control_host.cc
+++ b/content/web_test/browser/web_test_control_host.cc
@@ -1894,6 +1894,13 @@
   }
 }
 
+void WebTestControlHost::SetIsXrOverlaySetup() {
+  if (main_window_ && main_window_->web_contents()) {
+    main_window_->web_contents()->ForEachRenderFrameHost(
+        [](RenderFrameHost* rfh) { rfh->SetIsXrOverlaySetup(); });
+  }
+}
+
 void WebTestControlHost::SetLCPPNavigationHint(
     blink::mojom::LCPCriticalPathPredictorNavigationTimeHintPtr hint) {
   lcpp_hint_ = *hint.get();
diff --git a/content/web_test/browser/web_test_control_host.h b/content/web_test/browser/web_test_control_host.h
index 798b6866..fc490c1a 100644
--- a/content/web_test/browser/web_test_control_host.h
+++ b/content/web_test/browser/web_test_control_host.h
@@ -275,6 +275,7 @@
   void DisableAutoResize(const gfx::Size& new_size) override;
   void GetClipboardReadState(GetClipboardReadStateCallback callback) override;
   void ResetClipboardReadTracking() override;
+  void SetIsXrOverlaySetup() override;
   void SetLCPPNavigationHint(
       blink::mojom::LCPCriticalPathPredictorNavigationTimeHintPtr hint)
       override;
diff --git a/content/web_test/common/web_test.mojom b/content/web_test/common/web_test.mojom
index 5a7b887..7d6c842 100644
--- a/content/web_test/common/web_test.mojom
+++ b/content/web_test/common/web_test.mojom
@@ -394,4 +394,10 @@
   // Provide the specified LCPP navigation time hint to next navigation.
   SetLCPPNavigationHint(
       blink.mojom.LCPCriticalPathPredictorNavigationTimeHint hint);
+
+  // Tells WebTestControlHost that a WebXR DOM Overlay session was
+  // configured for testing. Normally, this would be handled by VRServiceImpl,
+  // but the WebXR WPT implementation intercepts the VRService mojom and thus
+  // bypasses VRServiceImpl.
+  SetIsXrOverlaySetup();
 };
diff --git a/tools/metrics/histograms/metadata/xr/histograms.xml b/tools/metrics/histograms/metadata/xr/histograms.xml
index 822f90cf..9aef94c 100644
--- a/tools/metrics/histograms/metadata/xr/histograms.xml
+++ b/tools/metrics/histograms/metadata/xr/histograms.xml
@@ -32,6 +32,18 @@
   </summary>
 </histogram>
 
+<histogram name="XR.DOMOverlay.IsXrOverlayFullscreenValid" enum="Boolean"
+    expires_after="2026-11-01">
+  <owner>alcooper@chromium.org</owner>
+  <owner>xr-dev@chromium.org</owner>
+  <summary>
+    Logged when a renderer requests to enter fullscreen with the is_xr_overlay
+    flag set. Records true if the browser verified that a recent WebXR DOM
+    Overlay setup occurred, or false if the request was reset because no recent
+    setup was recorded.
+  </summary>
+</histogram>
+
 <histogram name="XR.OpenXR.LocalFloorExtAvailable" enum="Boolean"
     expires_after="2027-04-27">
   <owner>alcooper@chromium.org</owner>
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 40ff354..2a3793a 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -4768,6 +4768,121 @@
   EXPECT_TRUE(subframe->IsRenderFrameLive());
 }
 
+// Verify that EnterFullscreen drops the is_xr_overlay flag when no XR setup
+// was recorded by the browser.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       EnterFullscreenIgnoresXrOverlayFlagWithoutXrSetup) {
+  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();
+
+  GURL child_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
+  {
+    std::string js_str = base::StringPrintf(
+        "var frame = document.createElement('iframe'); "
+        "frame.src = '%s'; "
+        "frame.allowFullscreen = true; "
+        "document.body.appendChild(frame);",
+        child_url.spec().c_str());
+    EXPECT_TRUE(ExecJs(main_frame, js_str));
+    ASSERT_TRUE(WaitForLoadStop(web_contents));
+  }
+
+  RenderFrameHostImpl* subframe = root->child_at(0)->current_frame_host();
+
+  base::HistogramTester histogram_tester;
+
+  // Give subframe transient user activation.
+  subframe->UpdateUserActivationState(
+      blink::mojom::UserActivationUpdateType::kNotifyActivation,
+      blink::mojom::UserActivationNotificationType::kInteraction);
+
+  auto options = blink::mojom::FullscreenOptions::New();
+  options->is_xr_overlay = true;
+
+  subframe->EnterFullscreen(std::move(options), base::DoNothing());
+
+  // Wait for parent frame to process fullscreen change.
+  EXPECT_TRUE(ExecJs(main_frame,
+                     "new Promise(resolve => {"
+                     "  if (document.fullscreenElement) resolve();"
+                     "  else document.addEventListener('fullscreenchange', () "
+                     "=> resolve(), {once: true});"
+                     "});"));
+
+  // Parent frame's iframe should match :fullscreen but NOT :xr-overlay.
+  EXPECT_EQ(true,
+            EvalJs(main_frame,
+                   "document.querySelector('iframe').matches(':fullscreen')"));
+  EXPECT_EQ(false,
+            EvalJs(main_frame,
+                   "document.querySelector('iframe').matches(':xr-overlay')"));
+
+  histogram_tester.ExpectUniqueSample(
+      "XR.DOMOverlay.IsXrOverlayFullscreenValid", false, 1);
+}
+
+// Verify that EnterFullscreen preserves the is_xr_overlay flag when XR setup
+// was recorded by the browser.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       EnterFullscreenPropagatesXrOverlayFlagWithXrSetup) {
+  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();
+
+  GURL child_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
+  {
+    std::string js_str = base::StringPrintf(
+        "var frame = document.createElement('iframe'); "
+        "frame.src = '%s'; "
+        "frame.allowFullscreen = true; "
+        "document.body.appendChild(frame);",
+        child_url.spec().c_str());
+    EXPECT_TRUE(ExecJs(main_frame, js_str));
+    ASSERT_TRUE(WaitForLoadStop(web_contents));
+  }
+
+  RenderFrameHostImpl* subframe = root->child_at(0)->current_frame_host();
+
+  base::HistogramTester histogram_tester;
+
+  // Record that an XR overlay setup occurred.
+  subframe->SetIsXrOverlaySetup();
+
+  auto options = blink::mojom::FullscreenOptions::New();
+  options->is_xr_overlay = true;
+
+  subframe->EnterFullscreen(std::move(options), base::DoNothing());
+
+  // Wait for parent frame to process fullscreen change.
+  EXPECT_TRUE(ExecJs(main_frame,
+                     "new Promise(resolve => {"
+                     "  if (document.fullscreenElement) resolve();"
+                     "  else document.addEventListener('fullscreenchange', () "
+                     "=> resolve(), {once: true});"
+                     "});"));
+
+  // Parent frame's iframe SHOULD match :xr-overlay.
+  EXPECT_EQ(true,
+            EvalJs(main_frame,
+                   "document.querySelector('iframe').matches(':xr-overlay')"));
+
+  histogram_tester.ExpectUniqueSample(
+      "XR.DOMOverlay.IsXrOverlayFullscreenValid", true, 1);
+}
+
 // Regression test for browser-side validation of the allow-pointer-lock
 // sandbox attribute. A sandboxed frame without allow-pointer-lock should not
 // be able to acquire pointer lock via Mojo IPC.
diff --git a/third_party/blink/web_tests/wpt_internal/webxr/ar/iframe-oopif.sub.https.html b/third_party/blink/web_tests/wpt_internal/webxr/ar/iframe-oopif.sub.https.html
index 23a64d92..3fa5adc9 100644
--- a/third_party/blink/web_tests/wpt_internal/webxr/ar/iframe-oopif.sub.https.html
+++ b/third_party/blink/web_tests/wpt_internal/webxr/ar/iframe-oopif.sub.https.html
@@ -109,6 +109,25 @@
 };
 
 const run_iframe_child = () => {
+  // Our standard webxr-test.js test hook bypasses our browser-side state
+  // manager, VRServiceImpl. Part of VRServiceImpl's responsibility is ensuring
+  // that the browser process knows that the impending fullscreen request from
+  // blink is expected and approved/permitted, since otherwise we've already
+  // consumed the transient activation that would allow it. This test hook
+  // replicates informing the browser process that the pending fullscreen
+  // request from this renderer is expected.
+  const script = document.createElement('script');
+  script.type = 'module';
+  script.textContent = `
+    import {NonAssociatedWebTestControlHostRemote} from '/gen/content/web_test/common/web_test.mojom.m.js';
+    if (typeof Mojo !== 'undefined') {
+      const host = new NonAssociatedWebTestControlHostRemote();
+      host.$.bindNewPipeAndPassReceiver().bindInBrowser('process');
+      host.setIsXrOverlaySetup();
+    }
+  `;
+  document.head.appendChild(script);
+
   xr_session_promise_test(
     "DOM Overlay in iframe",
     testBasicProperties.bind(this, document.body),
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 40ff354..2a3793a 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -4768,6 +4768,121 @@
   EXPECT_TRUE(subframe->IsRenderFrameLive());
 }
 
+// Verify that EnterFullscreen drops the is_xr_overlay flag when no XR setup
+// was recorded by the browser.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       EnterFullscreenIgnoresXrOverlayFlagWithoutXrSetup) {
+  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();
+
+  GURL child_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
+  {
+    std::string js_str = base::StringPrintf(
+        "var frame = document.createElement('iframe'); "
+        "frame.src = '%s'; "
+        "frame.allowFullscreen = true; "
+        "document.body.appendChild(frame);",
+        child_url.spec().c_str());
+    EXPECT_TRUE(ExecJs(main_frame, js_str));
+    ASSERT_TRUE(WaitForLoadStop(web_contents));
+  }
+
+  RenderFrameHostImpl* subframe = root->child_at(0)->current_frame_host();
+
+  base::HistogramTester histogram_tester;
+
+  // Give subframe transient user activation.
+  subframe->UpdateUserActivationState(
+      blink::mojom::UserActivationUpdateType::kNotifyActivation,
+      blink::mojom::UserActivationNotificationType::kInteraction);
+
+  auto options = blink::mojom::FullscreenOptions::New();
+  options->is_xr_overlay = true;
+
+  subframe->EnterFullscreen(std::move(options), base::DoNothing());
+
+  // Wait for parent frame to process fullscreen change.
+  EXPECT_TRUE(ExecJs(main_frame,
+                     "new Promise(resolve => {"
+                     "  if (document.fullscreenElement) resolve();"
+                     "  else document.addEventListener('fullscreenchange', () "
+                     "=> resolve(), {once: true});"
+                     "});"));
+
+  // Parent frame's iframe should match :fullscreen but NOT :xr-overlay.
+  EXPECT_EQ(true,
+            EvalJs(main_frame,
+                   "document.querySelector('iframe').matches(':fullscreen')"));
+  EXPECT_EQ(false,
+            EvalJs(main_frame,
+                   "document.querySelector('iframe').matches(':xr-overlay')"));
+
+  histogram_tester.ExpectUniqueSample(
+      "XR.DOMOverlay.IsXrOverlayFullscreenValid", false, 1);
+}
+
+// Verify that EnterFullscreen preserves the is_xr_overlay flag when XR setup
+// was recorded by the browser.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       EnterFullscreenPropagatesXrOverlayFlagWithXrSetup) {
+  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();
+
+  GURL child_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
+  {
+    std::string js_str = base::StringPrintf(
+        "var frame = document.createElement('iframe'); "
+        "frame.src = '%s'; "
+        "frame.allowFullscreen = true; "
+        "document.body.appendChild(frame);",
+        child_url.spec().c_str());
+    EXPECT_TRUE(ExecJs(main_frame, js_str));
+    ASSERT_TRUE(WaitForLoadStop(web_contents));
+  }
+
+  RenderFrameHostImpl* subframe = root->child_at(0)->current_frame_host();
+
+  base::HistogramTester histogram_tester;
+
+  // Record that an XR overlay setup occurred.
+  subframe->SetIsXrOverlaySetup();
+
+  auto options = blink::mojom::FullscreenOptions::New();
+  options->is_xr_overlay = true;
+
+  subframe->EnterFullscreen(std::move(options), base::DoNothing());
+
+  // Wait for parent frame to process fullscreen change.
+  EXPECT_TRUE(ExecJs(main_frame,
+                     "new Promise(resolve => {"
+                     "  if (document.fullscreenElement) resolve();"
+                     "  else document.addEventListener('fullscreenchange', () "
+                     "=> resolve(), {once: true});"
+                     "});"));
+
+  // Parent frame's iframe SHOULD match :xr-overlay.
+  EXPECT_EQ(true,
+            EvalJs(main_frame,
+                   "document.querySelector('iframe').matches(':xr-overlay')"));
+
+  histogram_tester.ExpectUniqueSample(
+      "XR.DOMOverlay.IsXrOverlayFullscreenValid", true, 1);
+}
+
 // Regression test for browser-side validation of the allow-pointer-lock
 // sandbox attribute. A sandboxed frame without allow-pointer-lock should not
 // be able to acquire pointer lock via Mojo IPC.
diff --git a/third_party/blink/web_tests/wpt_internal/webxr/ar/iframe-oopif.sub.https.html b/third_party/blink/web_tests/wpt_internal/webxr/ar/iframe-oopif.sub.https.html
index 23a64d92..3fa5adc9 100644
--- a/third_party/blink/web_tests/wpt_internal/webxr/ar/iframe-oopif.sub.https.html
+++ b/third_party/blink/web_tests/wpt_internal/webxr/ar/iframe-oopif.sub.https.html
@@ -109,6 +109,25 @@
 };
 
 const run_iframe_child = () => {
+  // Our standard webxr-test.js test hook bypasses our browser-side state
+  // manager, VRServiceImpl. Part of VRServiceImpl's responsibility is ensuring
+  // that the browser process knows that the impending fullscreen request from
+  // blink is expected and approved/permitted, since otherwise we've already
+  // consumed the transient activation that would allow it. This test hook
+  // replicates informing the browser process that the pending fullscreen
+  // request from this renderer is expected.
+  const script = document.createElement('script');
+  script.type = 'module';
+  script.textContent = `
+    import {NonAssociatedWebTestControlHostRemote} from '/gen/content/web_test/common/web_test.mojom.m.js';
+    if (typeof Mojo !== 'undefined') {
+      const host = new NonAssociatedWebTestControlHostRemote();
+      host.$.bindNewPipeAndPassReceiver().bindInBrowser('process');
+      host.setIsXrOverlaySetup();
+    }
+  `;
+  document.head.appendChild(script);
+
   xr_session_promise_test(
     "DOM Overlay in iframe",
     testBasicProperties.bind(this, document.body),
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.