Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Actor
DescriptionInsufficient validation of untrusted input in Actor
ComponentActor
Bug ClassLogic Error
Tracker517789833
Fix commit087822d5c2de (chromium/src) +173/-94
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
chrome/browser/actor/tools/page_target_util.cc
modified

Files Changed

  • chrome/browser/actor/tools/page_target_util.cc
  • chrome/browser/actor/tools/page_target_util.h
From 087822d5c2debaa323611cbb03d29ca63b7c6b19 Mon Sep 17 00:00:00 2001
From: Matthias Koerber <[email protected]>
Date: Mon, 22 Jun 2026 14:02:49 -0700
Subject: [PATCH] [Actor] Fix coordinate scaling in GetFieldIdFromPageTarget

This CL fixes a logic error in GetFieldIdFromPageTarget where coordinates
were incorrectly scaled before performing a compositor hit-test validation.

1. APC lookup (FindLastObservedNodeForActionTarget) now correctly scales
   up DIP coordinates to device pixels (BlinkSpace), which is the
   expected coordinate space for APC geometry.
2. Compositor hit-test (FindWidgetAtPoint) now uses unscaled DIP
   coordinates as expected by the API. Previously, it incorrectly
   multiplied coordinates by the Device Scale Factor due to a logic error
   using InvScale(1.0f / dsf).

These changes ensure that the anti-spoofing validation correctly identifies
the target frame even on high-DPI displays.

Bug: 521753402
Fixed: 517789833
Change-Id: I5fc631f72bed42c9cdc33a78bb765517beb46ed5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7925869
Reviewed-by: David Bokan <[email protected]>
Auto-Submit: Matthias Körber <[email protected]>
Commit-Queue: David Bokan <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1650566}
---

diff --git a/chrome/browser/actor/tools/page_target_util.cc b/chrome/browser/actor/tools/page_target_util.cc
index dda385d3..df752cef 100644
--- a/chrome/browser/actor/tools/page_target_util.cc
+++ b/chrome/browser/actor/tools/page_target_util.cc
@@ -14,6 +14,7 @@
 #include "content/public/browser/web_contents.h"
 #include "third_party/abseil-cpp/absl/functional/overload.h"
 #include "ui/gfx/geometry/point.h"
+#include "ui/gfx/geometry/point_conversions.h"
 #include "ui/gfx/geometry/point_f.h"
 
 namespace actor {
@@ -60,36 +61,6 @@
   return root_frame;
 }
 
-}  // namespace
-
-RenderFrameHost* FindTargetLocalRootFrame(tabs::TabHandle tab_handle,
-                                          PageTarget target) {
-  tabs::TabInterface* tab = tab_handle.Get();
-  if (!tab) {
-    return nullptr;
-  }
-
-  content::WebContents& contents = *tab->GetContents();
-
-  if (std::holds_alternative<gfx::Point>(target)) {
-    content::RenderWidgetHost* target_rwh =
-        contents.FindWidgetAtPoint(gfx::PointF(std::get<gfx::Point>(target)));
-    if (!target_rwh) {
-      return nullptr;
-    }
-    return GetRootFrameForWidget(contents, target_rwh);
-  }
-
-  CHECK(std::holds_alternative<DomNode>(target));
-
-  content::RenderFrameHost* target_frame =
-      optimization_guide::GetRenderFrameForDocumentIdentifier(
-          *tab->GetContents(), std::get<DomNode>(target).document_identifier);
-
-  // After finding the target frame, walk up to its local root.
-  return GetLocalRoot(target_frame);
-}
-
 // Return TargetNodeInfo from hit test against last observed APC. Returns
 // std::nullopt if Target does not hit any node.
 std::optional<TargetNodeInfo> FindLastObservedNodeForActionTargetId(
@@ -116,32 +87,85 @@
     return std::nullopt;
   }
 
+  // TODO(crbug.com/426021822): FindNodeAtPoint does not handle corner cases
+  // like clip paths. Need more checks to ensure we don't drop actions
+  // unnecessarily.
   // TODO(rodneyding): Refactor FindNode* API to include optional target frame
   // document identifier to reduce search space.
   return optimization_guide::FindNodeAtPoint(*apc, target_blink_pixels);
 }
 
+}  // namespace
+
+// Return `TargetNodeInfo` from hit test against last observed APC. Returns
+// std::nullopt if Target does not hit any node.
+//
+// PageTarget coordinates are view-relative DIPs. This function handles
+// scaling to visual-viewport-relative device pixels (BlinkSpace) required by
+// APC hit testing.
 std::optional<TargetNodeInfo> FindLastObservedNodeForActionTarget(
     const AnnotatedPageContent* apc,
-    const PageTarget& target) {
+    const PageTarget& target,
+    tabs::TabInterface* tab) {
   return std::visit(
       absl::Overload{
           [&](const DomNode& node) {
             return FindLastObservedNodeForActionTargetId(apc, node);
           },
-          [&](const gfx::Point& point) {
-            return FindLastObservedNodeForActionTargetPoint(apc, point);
+          [&](const gfx::Point& point_dip) {
+            float dsf = 1.0f;
+            content::WebContents* contents = tab ? tab->GetContents() : nullptr;
+            content::RenderWidgetHostView* view =
+                contents ? contents->GetRenderWidgetHostView() : nullptr;
+            if (view) {
+              dsf = view->GetDeviceScaleFactor();
+            }
+            // APC hit testing requires visual-viewport-relative
+            // device pixels (BlinkSpace). PageTarget points are
+            // provided in DIPs.
+            return FindLastObservedNodeForActionTargetPoint(
+                apc, gfx::ScaleToRoundedPoint(point_dip, dsf));
           },
       },
       target);
 }
 
+RenderFrameHost* FindTargetLocalRootFrame(tabs::TabHandle tab_handle,
+                                          PageTarget target) {
+  tabs::TabInterface* tab = tab_handle.Get();
+  content::WebContents* contents = tab ? tab->GetContents() : nullptr;
+  if (!contents) {
+    return nullptr;
+  }
+
+  if (std::holds_alternative<gfx::Point>(target)) {
+    content::RenderWidgetHost* target_rwh =
+        contents->FindWidgetAtPoint(gfx::PointF(std::get<gfx::Point>(target)));
+    if (!target_rwh) {
+      return nullptr;
+    }
+    return GetRootFrameForWidget(*contents, target_rwh);
+  }
+
+  CHECK(std::holds_alternative<DomNode>(target));
+
+  content::RenderFrameHost* target_frame =
+      optimization_guide::GetRenderFrameForDocumentIdentifier(
+          *contents, std::get<DomNode>(target).document_identifier);
+
+  // After finding the target frame, walk up to its local root.
+  return GetLocalRoot(target_frame);
+}
+
 autofill::FieldGlobalId GetFieldIdFromPageTarget(
     const AnnotatedPageContent* last_observation,
     tabs::TabInterface* tab,
     const PageTarget& target) {
+  if (!tab) {
+    return {};
+  }
   if (std::optional<TargetNodeInfo> node_info =
-          FindLastObservedNodeForActionTarget(last_observation, target)) {
+          FindLastObservedNodeForActionTarget(last_observation, target, tab)) {
     if (content::WebContents* web_contents = tab->GetContents()) {
       if (RenderFrameHost* rfh =
               optimization_guide::GetRenderFrameForDocumentIdentifier(
@@ -153,25 +177,11 @@
         // a coordinate-targeted action to its own frame via a spoofed
         // popup_window.
         if (std::holds_alternative<gfx::Point>(target)) {
+          // FindWidgetAtPoint expects view-relative DIPs. PageTarget points are
+          // already in DIPs.
           const gfx::Point& point = std::get<gfx::Point>(target);
-          float dsf = 1.0f;
-          if (web_contents->GetRenderWidgetHostView()) {
-            dsf =
-                web_contents->GetRenderWidgetHostView()->GetDeviceScaleFactor();
-          }
-
-          // The target point is provided in visual-viewport-relative device
-          // pixels (BlinkSpace). We must scale it to view-relative DIPs first
-          // before passing to FindWidgetAtPoint. Testing the unscaled point
-          // would introduce an aliasing vulnerability under non-1.0 scale
-          // factors.
-          gfx::PointF point_dip(point);
-          if (dsf > 0.0f) {
-            point_dip.InvScale(1.0f / dsf);
-          }
-
           content::RenderWidgetHost* actual_rwh =
-              web_contents->FindWidgetAtPoint(point_dip);
+              web_contents->FindWidgetAtPoint(gfx::PointF(point));
           if (!actual_rwh || actual_rwh != rfh->GetRenderWidgetHost()) {
             return {};
           }
diff --git a/chrome/browser/actor/tools/page_target_util.h b/chrome/browser/actor/tools/page_target_util.h
index 3c1fe998..d47e0388 100644
--- a/chrome/browser/actor/tools/page_target_util.h
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/actor/tools/page_target_util_browsertest.cc b/chrome/browser/actor/tools/page_target_util_browsertest.cc
index 15d2dcee..963e6c3 100644
--- a/chrome/browser/actor/tools/page_target_util_browsertest.cc
+++ b/chrome/browser/actor/tools/page_target_util_browsertest.cc
@@ -11,6 +11,7 @@
 #include "chrome/browser/actor/actor_tab_data.h"
 #include "chrome/browser/actor/tools/tools_test_util.h"
 #include "chrome/browser/ui/browser.h"
+#include "chrome/browser/ui/browser_window.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
 #include "chrome/test/base/in_process_browser_test.h"
 #include "chrome/test/base/ui_test_utils.h"
@@ -19,6 +20,7 @@
 #include "components/tabs/public/tab_interface.h"
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/render_widget_host.h"
+#include "content/public/browser/render_widget_host_view.h"
 #include "content/public/browser/web_contents.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
@@ -114,4 +116,105 @@
   EXPECT_FALSE(resolved_id);
 }
 
+class PageTargetUtilSecurityHiDpiTest : public PageTargetUtilSecurityTest {
+ public:
+  void SetUpCommandLine(base::CommandLine* command_line) override {
+    PageTargetUtilSecurityTest::SetUpCommandLine(command_line);
+    command_line->AppendSwitchASCII("force-device-scale-factor", "2");
+  }
+};
+
+IN_PROC_BROWSER_TEST_F(PageTargetUtilSecurityHiDpiTest,
+                       GetFieldIdFromPageTarget_InvScaleBypassAtHiDpi) {
+  // 1. Load a page and inject a cross-origin OOPIF.
+  GURL main_url = embedded_https_test_server().GetURL("a.com", "/empty.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), main_url));
+
+  content::RenderFrameHost* main_frame = web_contents()->GetPrimaryMainFrame();
+  ASSERT_TRUE(
+      content::ExecJs(main_frame,
+                      "document.body.style.margin = '0';"
+                      "var f = document.createElement('iframe');"
+                      "f.id = 'test';"
+                      "f.style.position = 'absolute'; f.style.left = '100px'; "
+                      "f.style.top = '0px';"
+                      "f.style.width = '200px'; f.style.height = '200px';"
+                      "f.style.border = 'none';"
+                      "document.body.appendChild(f);"));
+
+  GURL iframe_url =
+      embedded_https_test_server().GetURL("b.com", "/title1.html");
+  ASSERT_TRUE(content::NavigateIframeToURL(web_contents(), "test", iframe_url));
+
+  content::RenderFrameHost* child_frame = content::ChildFrameAt(main_frame, 0);
+  ASSERT_TRUE(child_frame->IsCrossProcessSubframe());
+
+  // Wait for the hit test data to be fully propagated.
+  content::WaitForHitTestData(child_frame);
+
+  // Verify DSF=2.0
+  ASSERT_TRUE(web_contents()->GetRenderWidgetHostView());
+  float dsf = web_contents()->GetRenderWidgetHostView()->GetDeviceScaleFactor();
+  ASSERT_EQ(dsf, 2.0f);
+
+  // 2. Setup the security scenario.
+  // Target Point: DIP (150, 50). This is physically over the OOPIF (x:100-300).
+  //
+  // SECURITY SCENARIO:
+  // - A compromised renderer sends a spoofed APC claiming its own frame
+  //   (child_frame) has a popup at (150, 50) DIPs.
+  // - Because of the fix, the browser scales (150, 50) DIP -> (300, 100) DP.
+  // - FindLastObservedNodeForActionTarget(300, 100) DP correctly hits the
+  //   attacker's popup in the APC.
+  // - BUT, the compositor hit-test at (150, 50) DIP must confirm the frame.
+  // - If the compositor hit-test returns the main frame (due to clipping,
+  //   occlusion, or builder environmental quirks), the action MUST be blocked.
+  gfx::Point target_point_dip(150, 50);
+
+  // 3. Create a spoofed APC with a popup_window belonging to the attacker.
+  optimization_guide::proto::AnnotatedPageContent apc;
+  auto* popup = apc.mutable_popup_window();
+
+  auto* user_data = optimization_guide::DocumentIdentifierUserData::
+      GetOrCreateForCurrentDocument(child_frame);
+  std::string attacker_doc_id = user_data->serialized_token();
+
+  popup->mutable_opener_document_id()->set_serialized_token(attacker_doc_id);
+  auto* root_node = popup->mutable_root_node();
+  root_node->mutable_content_attributes()->set_common_ancestor_dom_node_id(
+      4242);
+  root_node->mutable_content_attributes()
+      ->mutable_interaction_info()
+      ->set_document_scoped_z_order(1);
+
+  // Set popup bounds in APC coordinates (visual-viewport device pixels).
+  // Input DIP (150, 50) scales to (300, 100) device pixels.
+  auto* geometry = root_node->mutable_content_attributes()->mutable_geometry();
+  auto* box = geometry->mutable_visible_bounding_box();
+  box->set_x(290);
+  box->set_y(90);
+  box->set_width(20);
+  box->set_height(20);
+
+  // 4. Test GetFieldIdFromPageTarget.
+  autofill::FieldGlobalId resolved_id =
+      GetFieldIdFromPageTarget(&apc, active_tab(), target_point_dip);
+
+  content::RenderWidgetHost* actual_rwh =
+      web_contents()->FindWidgetAtPoint(gfx::PointF(target_point_dip));
+
+  if (actual_rwh == child_frame->GetRenderWidgetHost()) {
+    // If the compositor resolves the frame correctly, the tool must also
+    // succeed. This verifies that coordinate scaling for APC lookup is correct.
+    ASSERT_TRUE(resolved_id);
+    EXPECT_EQ(resolved_id.frame_token,
+              autofill::LocalFrameToken(child_frame->GetFrameToken().value()));
+  } else {
+    // If the compositor hit-test doesn't reach the OOPIF (e.g. ChromeOS builder
+    // environment quirks), we must return empty to prevent a frame mismatch.
+    // This confirms the anti-spoofing security check is working.
+    EXPECT_FALSE(resolved_id);
+  }
+}
+
 }  // namespace actor
Loading diff…

Original Bug Report

reported by [email protected]

Potential autofill target validation bypass in GetFieldIdFromPageTarget via popup spoofing

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 vulnerability in GetFieldIdFromPageTarget allows a compromised renderer hosting an out-of-process iframe to spoof popup window coordinates and hijack coordinate-targeted autofill actions. Because the resolution logic lacks compositor-trusted hit-test cross-checks, coordinates targeting main-frame input fields can resolve to attacker-controlled fields. This could lead to the unauthorized exfiltration of sensitive autofill data to a cross-origin frame after user approval.

Affected files:

  • chrome/browser/actor/tools/page_target_util.cc
  • chrome/browser/actor/tools/attempt_form_filling_tool.cc
  • chrome/browser/actor/tools/attempt_otp_filling_tool.cc

Estimated timestamp from git blame: 2025-11-07

Root Cause Analysis

The function GetFieldIdFromPageTarget (defined in chrome/browser/actor/tools/page_target_util.cc at line 136) is used to resolve a coordinate or DomNode target into an autofill FieldGlobalId. For coordinate-based (gfx::Point) targets, it uses FindLastObservedNodeForActionTargetPoint (line 109) to query the last observed AnnotatedPageContent (APC) using optimization_guide::FindNodeAtPoint().

In components/optimization_guide/content/browser/page_content_proto_util.cc (at line 1666), FindNodeAtPoint prioritizes searching the popup_window if one exists:

if (base::FeatureList::IsEnabled(blink::features::kAIPageContentIncludePopupWindows) &&
    annotated_page_content.has_popup_window()) {
  std::optional<optimization_guide::TargetNodeInfo> target_node =
      FindNodeAtPointRecursive(
          annotated_page_content.popup_window().opener_document_id(),
          &annotated_page_content.popup_window().root_node(), coordinate,
          std::nullopt);
  if (target_node.has_value()) {
    return target_node;
  }
}

During page content extraction, the browser-side verification in GetRenderFrameInfo checks web_contents->GetPopupWidgets() to set render_frame_info.has_active_popup = true (components/optimization_guide/content/browser/page_content_proto_provider.cc:341). A compromised subframe renderer can satisfy this requirement by triggering a legitimate popup widget (such as a dropdown menu) from its own process. Once verified, the compromised renderer can respond to GetAIPageContent with spoofed popup layout bounds that overlap sensitive form inputs in the main frame.

When a coordinate-targeted action (such as AttemptFormFillingTool or AttemptOtpFillingTool) is validated via TimeOfUseValidation, GetFieldIdFromPageTarget resolves the coordinate. Since the spoofed popup bounds overlap the targeted coordinates, the hit test resolves to the attacker’s frame token and node ID.

Unlike standard PageTool actions, which perform compositor-trusted verification via ValidateTargetFrameCandidate (comparing the target’s coordinates using WebContents::FindWidgetAtPoint), GetFieldIdFromPageTarget has no hit-test verification to ensure that the target frame actually owns the widget at those coordinates. It blindly trusts the document identifier resolved from the page content data.

Potential Attack Scenario

Because our automated analysis is based on static analysis, these are potential steps that an attacker might follow to trigger the vulnerability:

  1. Setup: An attacker compromises a sandboxed renderer process hosting a cross-origin iframe (e.g., https://attacker.example) embedded within a trusted page (e.g., https://victim.example).
  2. Popup State Validation Bypass: The compromised iframe opens a legitimate dropdown or popup element to ensure has_active_popup evaluates to true for its process on the browser side.
  3. Content Spoofing: When the browser requests page content (GetAIPageContent), the compromised iframe responds with fake popup geometry bounds that overlap sensitive inputs (such as address or OTP fields) in the top-level main frame.
  4. Action Invocation: A coordinate-targeted autofill action targeting the main frame input is triggered by the user via the Glic/Actor assistant interface.
  5. Validation Bypass: GetFieldIdFromPageTarget resolves the target coordinates to the attacker’s frame token because the coordinate matches the spoofed popup bounds. Due to the lack of compositor-trusted verification, the resolution succeeds.
  6. UI Deception: In ActorFormFillingServiceImpl::GetSuggestions, the request’s origin shown in the user’s Glic consent dialog is hardcoded to the primary main frame’s origin (tab.GetContents()->GetPrimaryMainFrame()->GetLastCommittedOrigin()). Consequently, the user is prompted to authorize the fill into the trusted victim.example domain.
  7. Data Leakage: Upon user approval, the autofill driver routes the sensitive values to the attacker’s frame since the resolved FieldGlobalId contains the attacker’s frame token. The compromised iframe can then read and exfiltrate the populated values.

Suggested Fix

Introduce compositor hit-test validation inside GetFieldIdFromPageTarget (defined in chrome/browser/actor/tools/page_target_util.cc). Before returning the resolved FieldGlobalId, the function should perform a validation similar to ValidateTargetFrameCandidate to check that the candidate frame’s underlying widget matches the widget returned by WebContents::FindWidgetAtPoint at the targeted coordinate.

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


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