Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in PointerLock
DescriptionInsufficient validation of untrusted input in PointerLock
ComponentPointerLock
Bug ClassLogic Error
Tracker492211919
Fix commit80ddc28c7cb4 (chromium/src) +84/-0
CISA KEVNot listed
Disclosed2026-06-02

Files Changed

  • AUTHORS
  • content/browser/bad_message.h
  • content/browser/renderer_host/render_widget_host_delegate.cc
  • content/browser/renderer_host/render_widget_host_delegate.h
  • content/browser/renderer_host/render_widget_host_impl.cc
  • content/browser/security_exploit_browsertest.cc
  • content/browser/web_contents/web_contents_impl.cc
  • content/browser/web_contents/web_contents_impl.h
  • tools/metrics/histograms/metadata/stability/enums.xml
From 80ddc28c7cb4038a6a73b15f452c1239222fd036 Mon Sep 17 00:00:00 2001
From: Mihalis Haatainen <[email protected]>
Date: Fri, 17 Apr 2026 15:00:53 -0700
Subject: [PATCH] Fix missing browser-side sandbox enforcement for kPointerLock

RenderWidgetHostImpl::RequestMouseLock() does not check the
WebSandboxFlags::kPointerLock sandbox flag. A compromised renderer
inside a sandboxed frame (without allow-pointer-lock) can call
RequestMouseLock() via Mojo IPC directly, bypassing the renderer-side
check in PointerLockController::RequestPointerLock().

Add browser-side IsSandboxed(kPointerLock) check matching the pattern
of the kModals fix (Bug 491676472, fixed 2025-03-12).

Bug: 492211919
Change-Id: I504cbfc17b6e7e8484a9622939dc98fef2beb755
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7761269
Reviewed-by: Dave Tapuska <[email protected]>
Commit-Queue: Charlie Reis <[email protected]>
Reviewed-by: Charlie Reis <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1616880}
---

diff --git a/AUTHORS b/AUTHORS
index 7ec65ea..ebb541c 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1099,6 +1099,7 @@
 Mihai Maerean <[email protected]>
 Mihai Tica <[email protected]>
 Mihai Tica <[email protected]>
+Mihalis Haatainen <[email protected]>
 Mike Pennisi <[email protected]>
 Mike Tilburg <[email protected]>
 Mikhail Pozdnyakov <[email protected]>
diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h
index 4b9d513..d1b63fb 100644
--- a/content/browser/bad_message.h
+++ b/content/browser/bad_message.h
@@ -365,6 +365,7 @@
   RFH_OPEN_URL_INVALID_DISPOSITION = 337,
   RFH_ENTER_FULLSCREEN_PERMISSION_DENIED = 338,
   DT_DUPLICATE_CHILD_TARGET_CREATED = 339,
+  RWH_POINTER_LOCK_FROM_SANDBOXED_FRAME = 340,
 
   // Please add new elements here. The naming convention is abbreviated class
   // name (e.g. RenderFrameHost becomes RFH) plus a unique description of the
diff --git a/content/browser/renderer_host/render_widget_host_delegate.cc b/content/browser/renderer_host/render_widget_host_delegate.cc
index f180bdd..fd4b6b1 100644
--- a/content/browser/renderer_host/render_widget_host_delegate.cc
+++ b/content/browser/renderer_host/render_widget_host_delegate.cc
@@ -133,6 +133,11 @@
   return false;
 }
 
+bool RenderWidgetHostDelegate::IsPointerLockSandboxedForWidget(
+    RenderWidgetHostImpl* render_widget_host) {
+  return false;
+}
+
 bool RenderWidgetHostDelegate::RequestKeyboardLock(RenderWidgetHostImpl* host,
                                                    bool esc_key_locked) {
   return false;
diff --git a/content/browser/renderer_host/render_widget_host_delegate.h b/content/browser/renderer_host/render_widget_host_delegate.h
index e4c41fea..d83118c 100644
--- a/content/browser/renderer_host/render_widget_host_delegate.h
+++ b/content/browser/renderer_host/render_widget_host_delegate.h
@@ -258,6 +258,17 @@
   // pointer isn't locked.
   virtual RenderWidgetHostImpl* GetPointerLockWidget();
 
+  // Returns true if the owning frame of |render_widget_host| is sandboxed
+  // with the kPointerLock flag, meaning the pointer lock request should be
+  // denied. It is ok to only check the top-most frame of the widget, because
+  // any subframes within the widget will be at least as restrictive as it.
+  // Any additional restrictions imposed on subframes of the widget cannot be
+  // enforced by the browser process, because they share a renderer process
+  // with the top-most frame of the widget.
+  // Note: crbug.com/492211919
+  virtual bool IsPointerLockSandboxedForWidget(
+      RenderWidgetHostImpl* render_widget_host);
+
   // Returns true if we are waiting for the user to make a selection on the
   // pointer lock permission request dialog.
   virtual bool IsWaitingForPointerLockPrompt(
diff --git a/content/browser/renderer_host/render_widget_host_impl.cc b/content/browser/renderer_host/render_widget_host_impl.cc
index a97558e..6eef4da 100644
--- a/content/browser/renderer_host/render_widget_host_impl.cc
+++ b/content/browser/renderer_host/render_widget_host_impl.cc
@@ -3279,6 +3279,17 @@
     bool from_user_gesture,
     bool unadjusted_movement,
     input::InputRouterImpl::RequestMouseLockCallback response) {
+  // Browser-side enforcement of the kPointerLock sandbox flag.
+  // The renderer correctly blocks this via PointerLockController, but a
+  // compromised renderer can bypass that check via direct Mojo IPC.
+  // Note: crbug.com/492211919
+  if (delegate_ && delegate_->IsPointerLockSandboxedForWidget(this)) {
+    bad_message::ReceivedBadMessage(
+        GetProcess(), bad_message::RWH_POINTER_LOCK_FROM_SANDBOXED_FRAME);
+    std::move(response).Run(blink::mojom::PointerLockResult::kPermissionDenied,
+                            mojo::NullRemote());
+    return;
+  }
   if (IsPointerLocked()) {
     std::move(response).Run(blink::mojom::PointerLockResult::kAlreadyLocked,
                             /*context=*/mojo::NullRemote());
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index aaa2699..29527cd7 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -3658,4 +3658,38 @@
   EXPECT_TRUE(subframe->IsRenderFrameLive());
 }
 
+// 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.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       PointerLockDisallowedFromSandboxedFrame) {
+  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.sandbox = 'allow-scripts'; "
+        "frame.src = '%s'; "
+        "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();
+  EXPECT_TRUE(
+      subframe->IsSandboxed(network::mojom::WebSandboxFlags::kPointerLock));
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(subframe->GetProcess());
+  RenderWidgetHostImpl* rwh = subframe->GetRenderWidgetHost();
+  rwh->RequestMouseLock(false, false, base::DoNothing());
+  EXPECT_EQ(bad_message::RWH_POINTER_LOCK_FROM_SANDBOXED_FRAME,
+            kill_waiter.Wait());
+  EXPECT_FALSE(subframe->IsRenderFrameLive());
+}
+
 }  // namespace content
diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc
index da7bf6a8..73df454 100644
--- a/content/browser/web_contents/web_contents_impl.cc
+++ b/content/browser/web_contents/web_contents_impl.cc
@@ -5284,6 +5284,24 @@
   }
 }
 
+bool WebContentsImpl::IsPointerLockSandboxedForWidget(
+    RenderWidgetHostImpl* render_widget_host) {
+  // Check the sandbox flags of the frame that owns the requesting widget.
+  // It is ok to only check the top-most frame of the widget, because any
+  // subframes within the widget will be at least as restrictive as it. Any
+  // additional restrictions imposed on subframes of the widget cannot be
+  // enforced by the browser process, because they share a renderer process
+  // with the top-most frame of the widget.
+  // Note: crbug.com/492211919
+  for (FrameTreeNode* node : GetPrimaryFrameTree().Nodes()) {
+    RenderFrameHostImpl* rfh = node->current_frame_host();
+    if (rfh && rfh->GetRenderWidgetHost() == render_widget_host) {
+      return rfh->IsSandboxed(network::mojom::WebSandboxFlags::kPointerLock);
+    }
+  }
+  return false;
+}
+
 bool WebContentsImpl::HasPointerLock(RenderWidgetHostImpl* render_widget_host) {
   // To verify if the mouse is locked, the mouse_lock_widget_ needs to be
   // assigned to the widget that requested the mouse lock, and the top-level
diff --git a/content/browser/web_contents/web_contents_impl.h b/content/browser/web_contents/web_contents_impl.h
index 03b93177..a082cfe 100644
--- a/content/browser/web_contents/web_contents_impl.h
+++ b/content/browser/web_contents/web_contents_impl.h
@@ -1196,6 +1196,8 @@
   DevicePostureProviderImpl* GetDevicePostureProvider() override;
   bool GetResizable() override;
   void LostPointerLock(RenderWidgetHostImpl* render_widget_host) override;
+  bool IsPointerLockSandboxedForWidget(
+      RenderWidgetHostImpl* render_widget_host) override;
   bool HasPointerLock(RenderWidgetHostImpl* render_widget_host) override;
   RenderWidgetHostImpl* GetPointerLockWidget() override;
   bool OnRenderFrameProxyVisibilityChanged(
diff --git a/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml
index 74cc653..7bf5109e 100644
--- a/tools/metrics/histograms/metadata/stability/enums.xml
+++ b/tools/metrics/histograms/metadata/stability/enums.xml
@@ -506,6 +506,7 @@
   <int value="337" label="RFH_OPEN_URL_INVALID_DISPOSITION"/>
   <int value="338" label="RFH_ENTER_FULLSCREEN_PERMISSION_DENIED"/>
   <int value="339" label="DT_DUPLICATE_CHILD_TARGET_CREATED"/>
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 aaa2699..29527cd7 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -3658,4 +3658,38 @@
   EXPECT_TRUE(subframe->IsRenderFrameLive());
 }
 
+// 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.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       PointerLockDisallowedFromSandboxedFrame) {
+  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.sandbox = 'allow-scripts'; "
+        "frame.src = '%s'; "
+        "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();
+  EXPECT_TRUE(
+      subframe->IsSandboxed(network::mojom::WebSandboxFlags::kPointerLock));
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(subframe->GetProcess());
+  RenderWidgetHostImpl* rwh = subframe->GetRenderWidgetHost();
+  rwh->RequestMouseLock(false, false, base::DoNothing());
+  EXPECT_EQ(bad_message::RWH_POINTER_LOCK_FROM_SANDBOXED_FRAME,
+            kill_waiter.Wait());
+  EXPECT_FALSE(subframe->IsRenderFrameLive());
+}
+
 }  // namespace content
Loading diff…

Original Bug Report

reported by [email protected]

Missing Browser-Side Sandbox Enforcement for kPointerLock

Steps to reproduce the problem

  1. RenderWidgetHostImpl::RequestMouseLock() does not check the WebSandboxFlags::kPointerLock sandbox flag. A compromised renderer inside a sandboxed frame (without allow-pointer-lock) can call FrameWidgetInputHandler::RequestMouseLock over Mojo IPC directly, bypassing the renderer-side check and obtaining pointer lock from the browser process.

Severity

High - Sandbox policy bypass from compromised renderer process.

Comparable to Bug 491676472 (allow-modals missing browser-side check, fixed 2025-03-12). That bug was assigned Security_Severity-High and fixed with bad_message::ReceivedBadMessage().

Component

  • Primary: content/browser/renderer_host/render_widget_host_impl.cc
  • Secondary: content/browser/web_contents/web_contents_impl.cc
  • Mojo interface: blink.mojom.FrameWidgetInputHandler::RequestMouseLock

Affected Versions

All current Chrome stable/beta versions (unfixed as of 2026-03-12):

  • Chrome 145.0.7632.160 (Stable, arm64, macOS) - verified
  • Chrome 146.0.7680.72 (next Stable, branch pos 1582197) - unfixed
  • Chromium main - unfixed

Note: The comparable kModals fix (Bug 491676472) landed at Cr-Commit-Position 1598123, which is newer than both 145 stable (pos ~1568190) and 146 stable (pos ~1582197). kPointerLock fix has not landed anywhere.

Problem Description

Root Cause

Renderer-side (PointerLockController::RequestPointerLock) correctly checks the sandbox flag and blocks the request:

// third_party/blink/renderer/core/page/pointer_lock_controller.cc:94
if (window->IsSandboxed(WebSandboxFlags::kPointerLock)) {
    resolver->RejectWithSecurityError(...);
    return;
}
// calls: GetWidgetForLocalRoot()->RequestMouseLock(...)

But the browser-side handler does NOT re-validate:

// content/browser/renderer_host/render_widget_host_impl.cc:3281
void RenderWidgetHostImpl::RequestMouseLock(
    bool from_user_gesture,
    bool unadjusted_movement,
    RequestMouseLockCallback response) {
  // ❌ MISSING: IsSandboxed(WebSandboxFlags::kPointerLock) check
  if (IsPointerLocked()) { ... }
  if (!view_ || !view_->CanBePointerLocked()) { ... }
  delegate_->RequestToLockPointer(this, from_user_gesture, ...);
}

WebContentsImpl::RequestToLockPointer (called next) checks for fenced frames but NOT for sandboxed frames:

// content/browser/web_contents/web_contents_impl.cc:5091
void WebContentsImpl::RequestToLockPointer(...) {
  if (render_widget_host->frame_tree()->is_fenced_frame()) {
    ReceivedBadMessage(...);  // ✅ fenced frame check
    return;
  }
  // ❌ NO: GetSandboxFlags() & WebSandboxFlags::kPointerLock
  delegate_->RequestPointerLock(this, user_gesture, ...);
}

Attack Scenario

As an attacker with code execution in a renderer process (e.g. via a V8 or Blink memory corruption bug):

  1. The renderer is hosting a sandboxed iframe (sandbox=“allow-scripts”, no allow-pointer-lock).
  2. Normal pointer lock is blocked by renderer check.
  3. Attacker patches renderer or calls Mojo IPC directly: FrameWidgetInputHandler::RequestMouseLock(true, false, callback)
  4. Browser grants pointer lock - cursor disappears, all mouse movement events captured by the sandboxed frame.

As an attacker I could:

  • Capture the mouse cursor from a privileged parent frame context
  • Perform UI redress attacks against the user (cursor hidden, fake UI shown)
  • Bypass the allow-pointer-lock sandboxing contract that sites rely on to safely embed third-party content

PoC

See attached index.html. It demonstrates:

  1. The renderer-side check correctly blocks pointer lock via normal JS.
  2. The missing browser-side check (shown via code reference).
  3. A compromised renderer can call the Mojo IPC directly to bypass.

To reproduce the FULL bypass (requires patched renderer or Mojo fuzzer):

# Call FrameWidgetInputHandler::RequestMouseLock(true, false)
# from a renderer hosting a sandbox="allow-scripts" iframe
# without allow-pointer-lock. Browser grants the lock.

Comparison: Bug 491676472 (Fixed)

The just-fixed allow-modals bug had the identical pattern:

// BEFORE fix: RunJavaScriptDialog() had no IsSandboxed(kModals) check
// AFTER fix (render_frame_host_impl.cc:7251):
if (IsSandboxed(WebSandboxFlags::kModals)) {
    bad_message::ReceivedBadMessage(GetProcess(),
        bad_message::RFH_JS_DIALOG_FROM_SANDBOXED_FRAME);
    return;
}

Proposed Fix

In RenderWidgetHostImpl::RequestMouseLock():

// Get the focused frame and check its sandbox flags
if (RenderFrameHostImpl* rfh = GetFocusedFrame()) {
  if (rfh->IsSandboxed(WebSandboxFlags::kPointerLock)) {
    bad_message::ReceivedBadMessage(
        GetProcess(),
        bad_message::RWHI_POINTER_LOCK_FROM_SANDBOXED_FRAME);
    std::move(response).Run(
        blink::mojom::PointerLockResult::kPermissionDenied,
        mojo::NullRemote());
    return;
  }
}

Also add corresponding test to content/browser/security_exploit_browsertest.cc (same file as the kModals fix test).

Additional Notes

  • Other sandbox flags may have similar missing browser-side checks. Recommend auditing: kDownloads, kOrientationLock, kPresentationController, kDocumentDomain.
  • kPopups already has browser-side check (CreateNewWindow, line 10092).
  • kTopNavigationByUserActivation has browser-side check (line 983).
  • kModals just got its check (Bug 491676472).

Summary

Missing Browser-Side Sandbox Enforcement for kPointerLock

Additional Data

Category: Security
Chrome Channel: Stable
Regression: N/A \

View on issue tracker