Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Core
DescriptionUse after free in Core
ComponentCore
Sub-componentInternals>Core
Bug ClassUAF
Tracker518102808
Fix commitc32d5cd39188 (chromium/src) +166/-13
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

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
IN_PROC_BROWSER_TEST_F
content/browser/web_contents/web_contents_impl_browsertest.cc
modified

Files Changed

  • content/browser/renderer_host/render_frame_host_impl.h
  • content/browser/web_contents/web_contents_impl_browsertest.cc
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/web_contents/web_contents_impl_unittest.cc
  • content/browser/web_contents/web_contents_impl.cc
  • content/browser/web_contents/web_contents_impl.h
  • content/browser/renderer_host/render_frame_host_delegate.cc
  • content/browser/renderer_host/render_frame_host_delegate.h
From c32d5cd39188fd9ccca97aec979af3757f0b0312 Mon Sep 17 00:00:00 2001
From: Jonathan Ross <jonross@chromium.org>
Date: Wed, 16 Sep 2026 05:15:12 -0700
Subject: [PATCH] Validate transient activation for HTML fullscreen in delegate

Pass the requesting frame and XR overlay status to
RenderFrameHostDelegate::IsTransientActivationRequiredForHtmlFullscreen
so that transient activation policies are evaluated within the delegate.
Previously, RenderFrameHostImpl::EnterFullscreen checked recent XR
overlay setup directly at the call site, which placed the WebXR DOM
Overlay waiver above the post-exit cooldown and allowed repeated
fullscreen re-entries without a fresh user gesture.

By moving the WebXR DOM Overlay waiver into WebContentsImpl below the
post-exit cooldown check, fullscreen requests shortly after an exit
strictly require and consume transient activation.

Additionally, validate timestamps in HasSeenRecentXrOverlaySetup by
checking for null and non-negative intervals to prevent false matches
against uninitialized setup times.

TAG=agy
CONV=a3bec6c0-33aa-4736-b14b-f23698448d5e

Bug: 518102808
Change-Id: I4aba18d031aaa275160a07d551fdee61730c33d2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8391647
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Commit-Queue: Jonathan Ross <jonross@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1698848}
---
diff --git a/content/browser/renderer_host/render_frame_host_delegate.cc b/content/browser/renderer_host/render_frame_host_delegate.cc
index 69032f0..966d7efd 100644
--- a/content/browser/renderer_host/render_frame_host_delegate.cc
+++ b/content/browser/renderer_host/render_frame_host_delegate.cc
@@ -171,7 +171,9 @@
   return std::nullopt;
 }
 
-bool RenderFrameHostDelegate::IsTransientActivationRequiredForHtmlFullscreen() {
+bool RenderFrameHostDelegate::IsTransientActivationRequiredForHtmlFullscreen(
+    RenderFrameHostImpl* requesting_frame,
+    bool is_xr_overlay) {
   return true;
 }
 
diff --git a/content/browser/renderer_host/render_frame_host_delegate.h b/content/browser/renderer_host/render_frame_host_delegate.h
index 6b11b24..2dfdd9a 100644
--- a/content/browser/renderer_host/render_frame_host_delegate.h
+++ b/content/browser/renderer_host/render_frame_host_delegate.h
@@ -653,7 +653,11 @@
           receiver) {}
 
   // Return whether HTML Fullscreen requires transient activation.
-  virtual bool IsTransientActivationRequiredForHtmlFullscreen();
+  // `requesting_frame` is the frame requesting fullscreen, and `is_xr_overlay`
+  // indicates whether the request is for a WebXR DOM Overlay.
+  virtual bool IsTransientActivationRequiredForHtmlFullscreen(
+      RenderFrameHostImpl* requesting_frame,
+      bool is_xr_overlay);
 
   // Return true if the back forward cache is supported. This is not an
   // indication that the cache will be used.
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 4d28c8a..53dd79f 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -9464,9 +9464,12 @@
   // 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) {
+    if (rfh->last_xr_overlay_setup_time_.is_null()) {
+      return;
+    }
     base::TimeDelta delta =
         base::TimeTicks::Now() - rfh->last_xr_overlay_setup_time_;
-    if (delta <= kMaxInterval) {
+    if (delta >= base::TimeDelta() && delta <= kMaxInterval) {
       found_recent_setup = true;
     }
   });
@@ -9514,8 +9517,8 @@
 
   // Entering fullscreen generally requires a transient user activation signal,
   // or another feature-specific transient allowance.
-  if (delegate_->IsTransientActivationRequiredForHtmlFullscreen() &&
-      !HasSeenRecentXrOverlaySetup()) {
+  if (delegate_->IsTransientActivationRequiredForHtmlFullscreen(
+          this, options->is_xr_overlay)) {
     // Reject requests made without transient user activation or a token.
     // TODO(lanwei): Investigate whether we can terminate the renderer when
     // transient user activation and the delegated token are both inactive.
diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h
index 7e846e4..18b3066 100644
--- a/content/browser/renderer_host/render_frame_host_impl.h
+++ b/content/browser/renderer_host/render_frame_host_impl.h
@@ -610,6 +610,7 @@
   void UpdateToAdFrame() override;
   bool IsAdFrame() const override;
   void SetIsXrOverlaySetup() override;
+  bool HasSeenRecentXrOverlaySetup();
   ukm::SourceId GetPageUkmSourceId() override;
   StoragePartitionImpl* GetStoragePartition() override;
   BrowserContext* GetBrowserContext() override;
@@ -4107,9 +4108,6 @@
                               bool success,
                               const std::u16string& user_input);
 
-  // See |SetIsXrOverlaySetup()|
-  bool HasSeenRecentXrOverlaySetup();
-
   bool has_unload_handlers() const {
     return has_unload_handler_ || has_pagehide_handler_ ||
            has_visibilitychange_handler_ || do_not_delete_for_testing_;
diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc
index 2db5397..c01bcd9 100644
--- a/content/browser/web_contents/web_contents_impl.cc
+++ b/content/browser/web_contents/web_contents_impl.cc
@@ -12480,7 +12480,9 @@
   screen_orientation_provider_->BindScreenOrientation(rfh, std::move(receiver));
 }
 
-bool WebContentsImpl::IsTransientActivationRequiredForHtmlFullscreen() {
+bool WebContentsImpl::IsTransientActivationRequiredForHtmlFullscreen(
+    RenderFrameHostImpl* requesting_frame,
+    bool is_xr_overlay) {
   // Allow fullscreen if the screen orientation changed in the last 1 second.
   static constexpr base::TimeDelta kMaxInterval = base::Seconds(1);
   const base::TimeDelta delta =
@@ -12490,9 +12492,12 @@
   }
 
   // Require transient activation shortly after a same-origin WebContents exit.
-  RenderFrameHostImpl* host = GetPrimaryMainFrame();
+  RenderFrameHostImpl* primary_main_frame = GetPrimaryMainFrame();
+  RenderFrameHostImpl* host =
+      requesting_frame ? requesting_frame : primary_main_frame;
   auto* last_exits = GetFullscreenUserData(GetBrowserContext())->last_exits();
-  auto last_exit = last_exits->find(host->GetLastCommittedOrigin());
+  auto last_exit =
+      last_exits->find(primary_main_frame->GetLastCommittedOrigin());
   constexpr base::TimeDelta kCooldown = base::Seconds(5);
   if (last_exit != last_exits->end() &&
       base::TimeTicks::Now() < last_exit->second + kCooldown) {
@@ -12504,6 +12509,11 @@
     return false;
   }
 
+  // Waive transient activation requirements for verified WebXR DOM overlay.
+  if (is_xr_overlay && host->HasSeenRecentXrOverlaySetup()) {
+    return false;
+  }
+
   return true;
 }
 
diff --git a/content/browser/web_contents/web_contents_impl.h b/content/browser/web_contents/web_contents_impl.h
index 58d38d1..2b1eacc 100644
--- a/content/browser/web_contents/web_contents_impl.h
+++ b/content/browser/web_contents/web_contents_impl.h
@@ -930,7 +930,9 @@
       RenderFrameHost* rfh,
       mojo::PendingAssociatedReceiver<device::mojom::ScreenOrientation>
           receiver) override;
-  bool IsTransientActivationRequiredForHtmlFullscreen() override;
+  bool IsTransientActivationRequiredForHtmlFullscreen(
+      RenderFrameHostImpl* requesting_frame,
+      bool is_xr_overlay) override;
   bool IsBackForwardCacheSupported() override;
   RenderWidgetHostImpl* CreateNewPopupWidget(
       base::SafeRef<SiteInstanceGroup> site_instance_group,
diff --git a/content/browser/web_contents/web_contents_impl_browsertest.cc b/content/browser/web_contents/web_contents_impl_browsertest.cc
index ee02f1e..78cbc8ee 100644
--- a/content/browser/web_contents/web_contents_impl_browsertest.cc
+++ b/content/browser/web_contents/web_contents_impl_browsertest.cc
@@ -4405,6 +4405,139 @@
   ASSERT_EQ(title, u"onfullscreenerror");
 }
 
+IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest,
+                       XrOverlayWaiverRespectsFullscreenCooldown) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  TestWCDelegateForDialogsAndFullscreen test_delegate(web_contents);
+
+  GURL url = embedded_test_server()->GetURL("a.com", "/title1.html");
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+  RenderFrameHostImpl* main_frame = web_contents->GetPrimaryMainFrame();
+
+  // Precondition: the frame has no transient user activation.
+  EXPECT_FALSE(main_frame->HasTransientUserActivation());
+
+  // Simulate exiting fullscreen, which records the exit timestamp and starts
+  // the post-exit cooldown.
+  web_contents->ExitFullscreenMode(/*will_cause_resize=*/false);
+
+  // The cooldown is now active: transient activation is required.
+  EXPECT_TRUE(web_contents->IsTransientActivationRequiredForHtmlFullscreen(
+      main_frame, /*is_xr_overlay=*/false));
+
+  // Baseline: with cooldown active and no XR overlay setup, entering fullscreen
+  // without user activation is rejected.
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_FALSE(future.Get());
+    EXPECT_FALSE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+
+  EXPECT_FALSE(main_frame->HasTransientUserActivation());
+
+  // Setup XR overlay timestamp.
+  main_frame->SetIsXrOverlaySetup();
+
+  // Fullscreen requests during cooldown without transient user activation must
+  // still be rejected, even if XR overlay setup occurred.
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    options->is_xr_overlay = true;
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_FALSE(future.Get());
+    EXPECT_FALSE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    options->is_xr_overlay = false;
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_FALSE(future.Get());
+    EXPECT_FALSE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+
+  // When legitimate transient user activation is provided during cooldown,
+  // entering fullscreen succeeds.
+  main_frame->UpdateUserActivationState(
+      blink::mojom::UserActivationUpdateType::kNotifyActivation,
+      blink::mojom::UserActivationNotificationType::kInteraction);
+  EXPECT_TRUE(main_frame->HasTransientUserActivation());
+
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_TRUE(future.Get());
+    EXPECT_TRUE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+}
+
+IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest,
+                       XrOverlayWaiverRequiresVerifiedXrSetup) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  TestWCDelegateForDialogsAndFullscreen test_delegate(web_contents);
+
+  GURL url = embedded_test_server()->GetURL("a.com", "/title1.html");
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+  RenderFrameHostImpl* main_frame = web_contents->GetPrimaryMainFrame();
+
+  // Precondition: cooldown is inactive and frame has no user activation.
+  EXPECT_FALSE(main_frame->HasTransientUserActivation());
+  EXPECT_TRUE(web_contents->IsTransientActivationRequiredForHtmlFullscreen(
+      main_frame, /*is_xr_overlay=*/false));
+
+  // Requesting is_xr_overlay without actual XR overlay setup is rejected.
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    options->is_xr_overlay = true;
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_FALSE(future.Get());
+    EXPECT_FALSE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+
+  // Setup XR overlay timestamp.
+  main_frame->SetIsXrOverlaySetup();
+
+  // With verified XR setup and is_xr_overlay=true, transient activation is
+  // waived.
+  EXPECT_FALSE(web_contents->IsTransientActivationRequiredForHtmlFullscreen(
+      main_frame, /*is_xr_overlay=*/true));
+
+  // But a standard fullscreen request (is_xr_overlay=false) still requires
+  // activation.
+  EXPECT_TRUE(web_contents->IsTransientActivationRequiredForHtmlFullscreen(
+      main_frame, /*is_xr_overlay=*/false));
+
+  // Requesting standard fullscreen without activation fails despite XR setup.
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    options->is_xr_overlay = false;
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_FALSE(future.Get());
+    EXPECT_FALSE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+
+  // Requesting with is_xr_overlay=true succeeds without transient activation.
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    options->is_xr_overlay = true;
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_TRUE(future.Get());
+    EXPECT_TRUE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+}
+
 // Regression test for https://crbug.com/855018.
 // RenderFrameHostImpls exit fullscreen as soon as they are unloaded.
 IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest, FullscreenAfterFrameUnload) {
diff --git a/content/browser/web_contents/web_contents_impl_unittest.cc b/content/browser/web_contents/web_contents_impl_unittest.cc
index 5f5cdbf0..ee64777 100644
--- a/content/browser/web_contents/web_contents_impl_unittest.cc
+++ b/content/browser/web_contents/web_contents_impl_unittest.cc
@@ -1854,7 +1854,8 @@
   // fullscreen will fail.
   main_test_rfh()->EnterFullscreen(blink::mojom::FullscreenOptions::New(),
                                    base::BindOnce(&ExpectFalse));
-  EXPECT_TRUE(contents()->IsTransientActivationRequiredForHtmlFullscreen());
+  EXPECT_TRUE(contents()->IsTransientActivationRequiredForHtmlFullscreen(
+      main_test_rfh(), /*is_xr_overlay=*/false));
   EXPECT_FALSE(
       main_test_rfh()->frame_tree_node()->HasTransientUserActivation());
   EXPECT_FALSE(contents()->IsFullscreen());
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/web_contents/web_contents_impl_browsertest.cc b/content/browser/web_contents/web_contents_impl_browsertest.cc
index ee02f1e..78cbc8ee 100644
--- a/content/browser/web_contents/web_contents_impl_browsertest.cc
+++ b/content/browser/web_contents/web_contents_impl_browsertest.cc
@@ -4405,6 +4405,139 @@
   ASSERT_EQ(title, u"onfullscreenerror");
 }
 
+IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest,
+                       XrOverlayWaiverRespectsFullscreenCooldown) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  TestWCDelegateForDialogsAndFullscreen test_delegate(web_contents);
+
+  GURL url = embedded_test_server()->GetURL("a.com", "/title1.html");
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+  RenderFrameHostImpl* main_frame = web_contents->GetPrimaryMainFrame();
+
+  // Precondition: the frame has no transient user activation.
+  EXPECT_FALSE(main_frame->HasTransientUserActivation());
+
+  // Simulate exiting fullscreen, which records the exit timestamp and starts
+  // the post-exit cooldown.
+  web_contents->ExitFullscreenMode(/*will_cause_resize=*/false);
+
+  // The cooldown is now active: transient activation is required.
+  EXPECT_TRUE(web_contents->IsTransientActivationRequiredForHtmlFullscreen(
+      main_frame, /*is_xr_overlay=*/false));
+
+  // Baseline: with cooldown active and no XR overlay setup, entering fullscreen
+  // without user activation is rejected.
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_FALSE(future.Get());
+    EXPECT_FALSE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+
+  EXPECT_FALSE(main_frame->HasTransientUserActivation());
+
+  // Setup XR overlay timestamp.
+  main_frame->SetIsXrOverlaySetup();
+
+  // Fullscreen requests during cooldown without transient user activation must
+  // still be rejected, even if XR overlay setup occurred.
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    options->is_xr_overlay = true;
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_FALSE(future.Get());
+    EXPECT_FALSE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    options->is_xr_overlay = false;
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_FALSE(future.Get());
+    EXPECT_FALSE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+
+  // When legitimate transient user activation is provided during cooldown,
+  // entering fullscreen succeeds.
+  main_frame->UpdateUserActivationState(
+      blink::mojom::UserActivationUpdateType::kNotifyActivation,
+      blink::mojom::UserActivationNotificationType::kInteraction);
+  EXPECT_TRUE(main_frame->HasTransientUserActivation());
+
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_TRUE(future.Get());
+    EXPECT_TRUE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+}
+
+IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest,
+                       XrOverlayWaiverRequiresVerifiedXrSetup) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  TestWCDelegateForDialogsAndFullscreen test_delegate(web_contents);
+
+  GURL url = embedded_test_server()->GetURL("a.com", "/title1.html");
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+  RenderFrameHostImpl* main_frame = web_contents->GetPrimaryMainFrame();
+
+  // Precondition: cooldown is inactive and frame has no user activation.
+  EXPECT_FALSE(main_frame->HasTransientUserActivation());
+  EXPECT_TRUE(web_contents->IsTransientActivationRequiredForHtmlFullscreen(
+      main_frame, /*is_xr_overlay=*/false));
+
+  // Requesting is_xr_overlay without actual XR overlay setup is rejected.
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    options->is_xr_overlay = true;
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_FALSE(future.Get());
+    EXPECT_FALSE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+
+  // Setup XR overlay timestamp.
+  main_frame->SetIsXrOverlaySetup();
+
+  // With verified XR setup and is_xr_overlay=true, transient activation is
+  // waived.
+  EXPECT_FALSE(web_contents->IsTransientActivationRequiredForHtmlFullscreen(
+      main_frame, /*is_xr_overlay=*/true));
+
+  // But a standard fullscreen request (is_xr_overlay=false) still requires
+  // activation.
+  EXPECT_TRUE(web_contents->IsTransientActivationRequiredForHtmlFullscreen(
+      main_frame, /*is_xr_overlay=*/false));
+
+  // Requesting standard fullscreen without activation fails despite XR setup.
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    options->is_xr_overlay = false;
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_FALSE(future.Get());
+    EXPECT_FALSE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+
+  // Requesting with is_xr_overlay=true succeeds without transient activation.
+  {
+    base::test::TestFuture<bool> future;
+    auto options = blink::mojom::FullscreenOptions::New();
+    options->is_xr_overlay = true;
+    main_frame->EnterFullscreen(std::move(options), future.GetCallback());
+    EXPECT_TRUE(future.Get());
+    EXPECT_TRUE(test_delegate.IsFullscreenForTabOrPending(web_contents));
+  }
+}
+
 // Regression test for https://crbug.com/855018.
 // RenderFrameHostImpls exit fullscreen as soon as they are unloaded.
 IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest, FullscreenAfterFrameUnload) {
diff --git a/content/browser/web_contents/web_contents_impl_unittest.cc b/content/browser/web_contents/web_contents_impl_unittest.cc
index 5f5cdbf0..ee64777 100644
--- a/content/browser/web_contents/web_contents_impl_unittest.cc
+++ b/content/browser/web_contents/web_contents_impl_unittest.cc
@@ -1854,7 +1854,8 @@
   // fullscreen will fail.
   main_test_rfh()->EnterFullscreen(blink::mojom::FullscreenOptions::New(),
                                    base::BindOnce(&ExpectFalse));
-  EXPECT_TRUE(contents()->IsTransientActivationRequiredForHtmlFullscreen());
+  EXPECT_TRUE(contents()->IsTransientActivationRequiredForHtmlFullscreen(
+      main_test_rfh(), /*is_xr_overlay=*/false));
   EXPECT_FALSE(
       main_test_rfh()->frame_tree_node()->HasTransientUserActivation());
   EXPECT_FALSE(contents()->IsFullscreen());
Loading diff…

Original Bug Report

reported by vm...@google.com

Bypass of fullscreen post-exit cooldown via WebXR DOM Overlay waiver

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential logic flaw in HTML fullscreen enforcement allows a compromised renderer with WebXR permission to bypass the 5-second post-exit cooldown. By repeatedly requesting a WebXR session with DOM Overlay, the waiver can be re-armed to enter fullscreen without requiring or consuming transient activation. This could potentially allow a persistent fullscreen phishing trap that overrides browser-side security defenses.

Affected files:

  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/web_contents/web_contents_impl.cc
  • content/browser/xr/service/vr_service_impl.cc

Estimated timestamp from git blame: 2020-02-21

Potential Root Cause Analysis

When a user exits fullscreen (such as by pressing the Esc key), Chrome enforces a 5-second post-exit cooldown on that origin to prevent fullscreen traps. During this cooldown, any subsequent request to enter HTML fullscreen requires and consumes a fresh transient user activation signal (gesture), bypassing typical gesture-free waivers.

This cooldown is evaluated inside WebContentsImpl::IsTransientActivationRequiredForHtmlFullscreen() (content/browser/web_contents/web_contents_impl.cc):

// Require transient activation shortly after a same-origin WebContents exit.
RenderFrameHostImpl* host = GetPrimaryMainFrame();
auto* last_exits = GetFullscreenUserData(GetBrowserContext())->last_exits();
auto last_exit = last_exits->find(host->GetLastCommittedOrigin());
constexpr base::TimeDelta kCooldown = base::Seconds(5);
if (last_exit != last_exits->end() &&
    base::TimeTicks::Now() < last_exit->second + kCooldown) {
  return true;
}

However, in RenderFrameHostImpl::EnterFullscreen (content/browser/renderer_host/render_frame_host_impl.cc), the transient-activation requirement is evaluated and gated at the call-site as follows:

if (delegate_->IsTransientActivationRequiredForHtmlFullscreen() &&
    !HasSeenRecentXrOverlaySetup()) {

Because HasSeenRecentXrOverlaySetup() is checked as an external logical conjunction ANDed with the return value of IsTransientActivationRequiredForHtmlFullscreen(), if HasSeenRecentXrOverlaySetup() returns true, the entire transient user activation verification and consumption block is skipped. This allows a compromised renderer that has WebXR permission to enter HTML fullscreen without requiring or consuming transient user activation.


Potential Exploit Path / Attack Steps

Note: These are potential steps. Our tooling agent does not have the ability to run code or verify this via a live proof-of-concept.

An attacker on an origin that has previously been granted WebXR permissions (such as immersive-ar or immersive-vr with dom-overlay) could potentially execute the following sequence via a compromised renderer:

  1. Initial Interaction: The user interacts with the page once, granting transient user activation.
  2. WebXR Request: The compromised renderer requests a WebXR session with DOM Overlay support via the Mojo IPC interface VRServiceImpl::RequestSession.
  3. Waiver Armed: The browser-side implementation handles this at content/browser/xr/service/vr_service_impl.cc and calls render_frame_host_->SetIsXrOverlaySetup(), setting last_xr_overlay_setup_time_ and arming a 1-second window.
  4. First Fullscreen Entry: Within 1 second, the page enters fullscreen. Because HasSeenRecentXrOverlaySetup() is true, the browser grants fullscreen without checking or consuming transient user activation on the browser side.
  5. Bypass Local Consumption: The compromised renderer refrains from sending the Mojo activation consumption message to the browser, keeping the browser’s transient user activation state active.
  6. User Manual Exit: The user exits fullscreen (e.g., by pressing Esc). WebContentsImpl::ExitFullscreenMode writes the cooldown timestamp to last_exits[origin], but does not clear last_xr_overlay_setup_time_ or the browser-side user activation.
  7. Immediate Re-entry Loop: Upon exit, the compromised renderer immediately requests a new WebXR session. Since the browser-side user activation remains active, the check passes, and SetIsXrOverlaySetup() is called again, re-arming the 1-second window.
  8. Cooldown Bypassed: The compromised renderer immediately calls EnterFullscreen. Although the post-exit cooldown is active, HasSeenRecentXrOverlaySetup() returns true, short-circuiting the check. Fullscreen is re-granted without requiring a new user gesture.
  9. This loop can potentially be repeated indefinitely, trapping the user in a fullscreen loop.

Suggested Remediation

To mitigate this potential bypass, we suggest one of the following changes:

  1. Integrate the Waiver: Move the HasSeenRecentXrOverlaySetup() check inside IsTransientActivationRequiredForHtmlFullscreen(), ensuring it is evaluated below/after the post-exit cooldown check so that the cooldown always takes precedence.
  2. Clear on Exit: Explicitly clear or invalidate last_xr_overlay_setup_time_ in WebContentsImpl::ExitFullscreenMode alongside the writing of last_exits to prevent immediate re-use after a manual exit.

Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker