Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in iOSWeb
DescriptionInappropriate implementation in iOSWeb
ComponentChromium
Bug ClassLogic Error
Tracker505156685
Fix commit3316ee549613 (chromium/src) +84/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
TEST_F
ios/web/js_features/context_menu/context_menu_js_unittest.mm
modified
if
ios/web/js_features/context_menu/context_menu_js_unittest.mm
modified
if
ios/web/js_features/context_menu/resources/all_frames_context_menu.ts
modified

Files Changed

  • ios/testing/data/http_server_files/link.html
  • ios/testing/http_server_bundle_data.filelist
  • ios/web/js_features/context_menu/context_menu_js_unittest.mm
  • ios/web/js_features/context_menu/resources/all_frames_context_menu.ts
From 3316ee54961395c288f1e388b13f839f1cf566f8 Mon Sep 17 00:00:00 2001
From: Olivier ROBIN <olivierrobin@google.com>
Date: Mon, 18 May 2026 12:48:46 -0700
Subject: [PATCH] Use absolute coordinates when looking for context menu element

Previous code did not account for parent offset do did not work
if the iframe was included in an element that was itself offset.

Fixed: 505156685
Change-Id: Icd631eb83db3691d9607587f32235e94310022ad
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7837578
Reviewed-by: Mike Dougherty <michaeldo@chromium.org>
Commit-Queue: Olivier Robin <olivierrobin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1632350}
---

diff --git a/ios/testing/data/http_server_files/link.html b/ios/testing/data/http_server_files/link.html
new file mode 100644
index 0000000..6c5b0f7
--- /dev/null
+++ b/ios/testing/data/http_server_files/link.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+
+<!--
+Copyright 2026 The Chromium Authors
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+-->
+
+<!--
+This file contains just one link. The link is at a precise position to be able
+to tap it.
+-->
+
+<html>
+  <body>
+    <a href="pony.html" id="link" style="display:inline-block;position:absolute;left:40px;top:40px;width:10px;height:10px;">Link</a>
+  </body>
+</html>
diff --git a/ios/testing/http_server_bundle_data.filelist b/ios/testing/http_server_bundle_data.filelist
index 2557f862..ac88acf63 100644
--- a/ios/testing/http_server_bundle_data.filelist
+++ b/ios/testing/http_server_bundle_data.filelist
@@ -38,6 +38,7 @@
 data/http_server_files/iframe_form.html
 data/http_server_files/iframe_host.html
 data/http_server_files/insecure_form.html
+data/http_server_files/link.html
 data/http_server_files/links.html
 data/http_server_files/memory_usage.html
 data/http_server_files/multi_field_form.html
diff --git a/ios/web/js_features/context_menu/context_menu_js_unittest.mm b/ios/web/js_features/context_menu/context_menu_js_unittest.mm
index 1634f547..921ebb4c 100644
--- a/ios/web/js_features/context_menu/context_menu_js_unittest.mm
+++ b/ios/web/js_features/context_menu/context_menu_js_unittest.mm
@@ -1013,4 +1013,64 @@
   CheckElementResult(@"link", expected_value);
 }
 
+// Tests that `findElementAtPoint` finds a link inside an iframe.
+TEST_F(ContextMenuJsFindElementAtPointTest, LinkInsideIframe) {
+  // Use a server file in the iframe.
+  GURL iframe_url = test_server_.GetURL("/link.html");
+  NSString* iframe_src = base::SysUTF8ToNSString(iframe_url.spec());
+
+  NSString* body = [NSString
+      stringWithFormat:@"<div style='position:relative;left:40px;top:40px;'>"
+                       @"<iframe id='iframe' src='%@' "
+                       @"style='position:absolute;left:40px;top:40px;width:"
+                       @"100px;height:100px;border:none;'></iframe>"
+                       @"</div>",
+                       iframe_src];
+  NSString* html = GetHtmlForPage(nil, body);
+
+  // Use web::test::LoadHtml to set the same origin for the page and the iframe
+  // so iframe elements can be accessed.
+  ASSERT_TRUE(web::test::LoadHtml(
+      web_view(), html, net::NSURLWithGURL(test_server_.GetURL("/"))));
+
+  // Wait for the iframe and the link inside the iframe to load.
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
+    id iframe_exists = web::test::ExecuteJavaScript(
+        web_view(), @"document.getElementById('iframe') !== null");
+    if (![iframe_exists boolValue]) {
+      return NO;
+    }
+    // zoomScale starts at 1.0 before being set to its final value.
+    // As the correct zoomScale is required to find the element, wait until it
+    // has its final value.
+    if (web_view().scrollView.zoomScale == 1.0) {
+      return NO;
+    }
+    id link_exists = web::test::ExecuteJavaScript(
+        web_view(), @"var iframe = document.getElementById('iframe');"
+                    @"iframe && iframe.contentDocument && "
+                    @"iframe.contentDocument.getElementById('link') !== null");
+    return [link_exists boolValue];
+  }));
+
+  std::string expected_href = test_server_.GetURL("/pony.html").spec();
+  auto expected_value = base::DictValue()
+                            .Set(kContextMenuElementRequestId, kRequestId)
+                            .Set(kContextMenuElementInnerText, "Link")
+                            .Set(kContextMenuElementReferrerPolicy, "default")
+                            .Set(kContextMenuElementHyperlink, expected_href)
+                            .Set(kContextMenuElementTagName, "a");
+
+  // Retrieve and scale tap coordinate relative to the iframe's position.
+  // Position of the tap:
+  // 40 for the div position
+  // 40 for the iframe position
+  // 40 for the link position
+  // 5 to tap the center of the link
+  CGFloat scale = web_view().scrollView.zoomScale;
+  CGPoint tap_point = CGPointMake(125 * scale, 125 * scale);
+
+  CheckElementResult(tap_point, expected_value);
+}
+
 }  // namespace web
diff --git a/ios/web/js_features/context_menu/resources/all_frames_context_menu.ts b/ios/web/js_features/context_menu/resources/all_frames_context_menu.ts
index 49d44193..328cc212 100644
--- a/ios/web/js_features/context_menu/resources/all_frames_context_menu.ts
+++ b/ios/web/js_features/context_menu/resources/all_frames_context_menu.ts
@@ -387,11 +387,14 @@
 
   // if element is a frame, tell it to respond to this element request
   if (tagName === 'iframe' || tagName === 'frame') {
+    const rect = element.getBoundingClientRect();
+    const absoluteLeft = rect.left + window.pageXOffset;
+    const absoluteTop = rect.top + window.pageYOffset;
     const payload = {
       type: 'org.chromium.contextMenuMessage',
       requestId: requestId,
-      x: centerX - element.offsetLeft,
-      y: centerY - element.offsetTop,
+      x: centerX - absoluteLeft,
+      y: centerY - absoluteTop,
     };
     // The message will not be sent if `targetOrigin` is null or "about:blank",
     // so use * which allows the message to be delievered to the contentWindow
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/testing/data/http_server_files/link.html b/ios/testing/data/http_server_files/link.html
new file mode 100644
index 0000000..6c5b0f7
--- /dev/null
+++ b/ios/testing/data/http_server_files/link.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+
+<!--
+Copyright 2026 The Chromium Authors
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+-->
+
+<!--
+This file contains just one link. The link is at a precise position to be able
+to tap it.
+-->
+
+<html>
+  <body>
+    <a href="pony.html" id="link" style="display:inline-block;position:absolute;left:40px;top:40px;width:10px;height:10px;">Link</a>
+  </body>
+</html>
diff --git a/ios/testing/http_server_bundle_data.filelist b/ios/testing/http_server_bundle_data.filelist
index 2557f862..ac88acf63 100644
--- a/ios/testing/http_server_bundle_data.filelist
+++ b/ios/testing/http_server_bundle_data.filelist
@@ -38,6 +38,7 @@
 data/http_server_files/iframe_form.html
 data/http_server_files/iframe_host.html
 data/http_server_files/insecure_form.html
+data/http_server_files/link.html
 data/http_server_files/links.html
 data/http_server_files/memory_usage.html
 data/http_server_files/multi_field_form.html
diff --git a/ios/web/js_features/context_menu/context_menu_js_unittest.mm b/ios/web/js_features/context_menu/context_menu_js_unittest.mm
index 1634f547..921ebb4c 100644
--- a/ios/web/js_features/context_menu/context_menu_js_unittest.mm
+++ b/ios/web/js_features/context_menu/context_menu_js_unittest.mm
@@ -1013,4 +1013,64 @@
   CheckElementResult(@"link", expected_value);
 }
 
+// Tests that `findElementAtPoint` finds a link inside an iframe.
+TEST_F(ContextMenuJsFindElementAtPointTest, LinkInsideIframe) {
+  // Use a server file in the iframe.
+  GURL iframe_url = test_server_.GetURL("/link.html");
+  NSString* iframe_src = base::SysUTF8ToNSString(iframe_url.spec());
+
+  NSString* body = [NSString
+      stringWithFormat:@"<div style='position:relative;left:40px;top:40px;'>"
+                       @"<iframe id='iframe' src='%@' "
+                       @"style='position:absolute;left:40px;top:40px;width:"
+                       @"100px;height:100px;border:none;'></iframe>"
+                       @"</div>",
+                       iframe_src];
+  NSString* html = GetHtmlForPage(nil, body);
+
+  // Use web::test::LoadHtml to set the same origin for the page and the iframe
+  // so iframe elements can be accessed.
+  ASSERT_TRUE(web::test::LoadHtml(
+      web_view(), html, net::NSURLWithGURL(test_server_.GetURL("/"))));
+
+  // Wait for the iframe and the link inside the iframe to load.
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
+    id iframe_exists = web::test::ExecuteJavaScript(
+        web_view(), @"document.getElementById('iframe') !== null");
+    if (![iframe_exists boolValue]) {
+      return NO;
+    }
+    // zoomScale starts at 1.0 before being set to its final value.
+    // As the correct zoomScale is required to find the element, wait until it
+    // has its final value.
+    if (web_view().scrollView.zoomScale == 1.0) {
+      return NO;
+    }
+    id link_exists = web::test::ExecuteJavaScript(
+        web_view(), @"var iframe = document.getElementById('iframe');"
+                    @"iframe && iframe.contentDocument && "
+                    @"iframe.contentDocument.getElementById('link') !== null");
+    return [link_exists boolValue];
+  }));
+
+  std::string expected_href = test_server_.GetURL("/pony.html").spec();
+  auto expected_value = base::DictValue()
+                            .Set(kContextMenuElementRequestId, kRequestId)
+                            .Set(kContextMenuElementInnerText, "Link")
+                            .Set(kContextMenuElementReferrerPolicy, "default")
+                            .Set(kContextMenuElementHyperlink, expected_href)
+                            .Set(kContextMenuElementTagName, "a");
+
+  // Retrieve and scale tap coordinate relative to the iframe's position.
+  // Position of the tap:
+  // 40 for the div position
+  // 40 for the iframe position
+  // 40 for the link position
+  // 5 to tap the center of the link
+  CGFloat scale = web_view().scrollView.zoomScale;
+  CGPoint tap_point = CGPointMake(125 * scale, 125 * scale);
+
+  CheckElementResult(tap_point, expected_value);
+}
+
 }  // namespace web
Loading diff…

Original Bug Report

reported by li...@chromium.org

Potential UI redressing and info leak via flawed iframe context menu hit-test on iOS

Flapjack, 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 without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: The iOS context menu hit-testing logic incorrectly calculates coordinates for iframes using offsetLeft and offsetTop, which are relative to the offsetParent rather than the document. An attacker can manipulate DOM positioning to arbitrarily shift the hit-test location inside a cross-origin iframe. Combined with a bypassed opacity check, this enables UI redressing where users are tricked into leaking sensitive cross-origin links.

Affected files:

  • ios/web/js_features/context_menu/resources/all_frames_context_menu.ts

Estimated timestamp from git blame: 2023-04-20

Description

When a user long-presses on a web page in Chromium for iOS, the browser determines the target element for the context menu. If the selected element is an iframe, the browser delegates the hit-test to that iframe by sending a coordinate payload via postMessage.

A vulnerability exists in the coordinate conversion logic within ios/web/js_features/context_menu/resources/all_frames_context_menu.ts. The function processElementForFindElementAtPoint calculates the coordinates to send to the iframe:

    const payload = {
      type: 'org.chromium.contextMenuMessage',
      requestId: requestId,
      x: centerX - element.offsetLeft,
      y: centerY - element.offsetTop,
    };

centerX and centerY are absolute page coordinates. However, element.offsetLeft and element.offsetTop are relative to the element’s offsetParent (the nearest positioned ancestor), not the absolute page root.

By nesting an iframe within a positioned container (e.g., a div with position: relative), an attacker can cause element.offsetLeft to return a value relative to that container rather than the document root. This results in incorrect, attacker-controlled coordinates being passed to the iframe for hit-testing.

Impact

This vulnerability potentially enables a UI redressing (Clickjacking) attack. An attacker can strategically position a decoy element and manipulate the DOM hierarchy such that a long-press on the decoy triggers a context menu hit-test on a different, sensitive element inside a cross-origin iframe.

If the user interacts with the resulting context menu (for example, by choosing “Copy Link”), they may inadvertently disclose sensitive cross-origin data (like password reset URLs or tokens) to their clipboard.

Potential Steps to Reproduce

Note: These are suggested theoretical steps based on static analysis, as our tooling agent cannot execute live code.

  1. Attacker Setup: The attacker embeds a cross-origin iframe pointing to a sensitive resource on victim.com.
  2. DOM Manipulation: The attacker wraps the iframe in a container element with position: absolute at an arbitrary location (e.g., left: 500px, top: 500px). This makes the container the offsetParent.
  3. Coordinate Shifting: The attacker applies CSS to the iframe: position: absolute; left: -500px; top: -500px;. Visually, the iframe appears at (0,0), but its offsetLeft and offsetTop are -500.
  4. Decoy Overlay: The attacker overlays a decoy image exactly at (0,0). To bypass the isOpaqueElement check (which uses an OPACITY_THRESHOLD of 0.9), the decoy’s opacity is set to 0.89.
  5. User Interaction: The user is tricked into long-pressing the decoy at (0,0).
  6. Hit-Test Failure: The script’s hit-test evaluates the decoy, considers it transparent due to the 0.89 opacity, and falls through to the iframe beneath it.
  7. Corrupted Payload: processElementForFindElementAtPoint calculates the coordinates for the iframe: x = 0 - (-500) = 500. It sends {x: 500, y: 500} to the cross-origin iframe.
  8. Data Leak: The context menu is generated for the sensitive element at (500, 500) inside the victim iframe, rather than the user’s touch point. The user clicks “Copy Link”, leaking the cross-origin URL.

Suggested Fix

Calculate the absolute position of the iframe using getBoundingClientRect() combined with the parent window’s scroll offset (window.pageXOffset / pageYOffset). This ensures the conversion to iframe-local coordinates is accurate regardless of ancestor DOM positioning.

const rect = element.getBoundingClientRect();
const absoluteLeft = rect.left + window.pageXOffset;
const absoluteTop = rect.top + window.pageYOffset;
const payload = {
  // ...
  x: centerX - absoluteLeft,
  y: centerY - absoluteTop,
};

Evaluated with Chrome root at commit: 4a3e9db74111a3c6c4b3acfd70050a05077cf27a


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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