Low chrome Logic Error 🔧 Commit mapped

Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactConfused deputy in Fullscreen
DescriptionConfused deputy in Fullscreen
ComponentFullscreen
Bug ClassLogic Error
Tracker515426792
Fix commit4580ad5bc396 (chromium/src) +183/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

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

Files Changed

  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/security_exploit_browsertest.cc
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/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')"));
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.