CVE-2026-87502
Overview
Background
- `is_xr_overlay`
- a boolean field on the renderer-supplied
blink::mojom::FullscreenOptionsthat, when set, causes ancestor renderers to mark theirDocumentas a WebXR DOM Overlay (applying the:xr-overlayUA stylesheet, short-circuitingLocalFrameView::PaintTree()to paint only the iframe layer, and rejecting subsequentrequestFullscreen()in the parent). - `RenderFrameHostImpl::EnterFullscreen`
- the browser-process handler that receives a frame’s fullscreen request and forwards its
FullscreenOptionsto cross-process ancestors viaRemoteFrame::WillEnterFullscreenso they can style the owning<iframe>. - `SetIsXrOverlaySetup` / `HasSeenRecentXrOverlaySetup`
- browser-side bookkeeping where
SetIsXrOverlaySetup()records a timestamp (last_xr_overlay_setup_time_) whenVRServiceImplgrants an immersive session with the DOM Overlay feature, andHasSeenRecentXrOverlaySetup()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.
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
- Embed a cross-process subframe
An attacker page (
a.com) embeds a cross-origin iframe (b.com, site-isolated) withallowFullscreen, as reconstructed by theEnterFullscreenIgnoresXrOverlayFlagWithoutXrSetupregression test. - Forge the overlay flag
The subframe’s renderer constructs
FullscreenOptionswithis_xr_overlay = truedespite no WebXR DOM Overlay session ever being granted, then invokesEnterFullscreen(backed by transient user activation). - Browser forwards the unvalidated flag
Pre-patch,
RenderFrameHostImpl::EnterFullscreenforwarded the options to the cross-process ancestor viaRemoteFrame::WillEnterFullscreenwithout checkingHasSeenRecentXrOverlaySetup(), so the ancestor renderer marked itsDocumentas an XR DOM Overlay. - Parent enters XR overlay mode
The ancestor applies the
:xr-overlayUA stylesheet, short-circuitsLocalFrameView::PaintTree()to paint only the iframe’s layer, and rejects the parent’s own subsequentrequestFullscreen()calls, confirmed by the test asserting the parent iframe matches:xr-overlayin the vulnerable state. - 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
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
| Function | Change | Notes |
|---|---|---|
ForEachRenderFrameHostImplcontent/browser/renderer_host/render_frame_host_impl.cc |
modified | |
ifcontent/browser/renderer_host/render_frame_host_impl.cc |
modified | |
ifthird_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.htmltools/metrics/histograms/metadata/xr/histograms.xmlcontent/web_test/common/web_test.mojomcontent/browser/renderer_host/render_frame_host_impl.cccontent/web_test/browser/web_test_control_host.cccontent/web_test/browser/web_test_control_host.hcontent/browser/security_exploit_browsertest.cc
Audit Directions
- Renderer-supplied option structs forwarded to other processesAudit other fields of
FullscreenOptionsand similar Mojo structs (e.g. any flag reforwarded viaRemoteFrame::WillEnterFullscreento ancestors) for the same missing browser-side authorization check. - Browser state keyed to the wrong frameReview browser-authoritative checks in OOPIF scenarios where a timestamp/flag is recorded on one
RenderFrameHostbut consulted on an ancestor’s, since the pre-fix single-frameHasSeenRecentXrOverlaySetup()shows this frame-mismatch is easy to introduce. - Feature flags gated on activation but not on capabilityLook 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.
Patch
From 4580ad5bc3968c52393b7877797282471885c00e Mon Sep 17 00:00:00 2001 From: Alexander Cooper <[email protected]> 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 <[email protected]> Auto-Submit: Alexander Cooper <[email protected]> Reviewed-by: Fred Shih <[email protected]> Commit-Queue: Fred Shih <[email protected]> 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>[email protected]</owner> + <owner>[email protected]</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>[email protected]</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),
Regression Test / PoC
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),