CVE-2026-13907
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fios/web/js_features/context_menu/context_menu_js_unittest.mm |
modified | |
ifios/web/js_features/context_menu/context_menu_js_unittest.mm |
modified | |
ifios/web/js_features/context_menu/resources/all_frames_context_menu.ts |
modified |
Files Changed
ios/testing/data/http_server_files/link.htmlios/testing/http_server_bundle_data.filelistios/web/js_features/context_menu/context_menu_js_unittest.mmios/web/js_features/context_menu/resources/all_frames_context_menu.ts
Patch
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
Regression Test / PoC
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
Original Bug Report
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.
- Attacker Setup: The attacker embeds a cross-origin iframe pointing to a sensitive resource on
victim.com. - DOM Manipulation: The attacker wraps the iframe in a container element with
position: absoluteat an arbitrary location (e.g.,left: 500px,top: 500px). This makes the container theoffsetParent. - Coordinate Shifting: The attacker applies CSS to the iframe:
position: absolute; left: -500px; top: -500px;. Visually, the iframe appears at(0,0), but itsoffsetLeftandoffsetTopare-500. - Decoy Overlay: The attacker overlays a decoy image exactly at
(0,0). To bypass theisOpaqueElementcheck (which uses anOPACITY_THRESHOLDof0.9), the decoy’s opacity is set to0.89. - User Interaction: The user is tricked into long-pressing the decoy at
(0,0). - Hit-Test Failure: The script’s hit-test evaluates the decoy, considers it transparent due to the
0.89opacity, and falls through to the iframe beneath it. - Corrupted Payload:
processElementForFindElementAtPointcalculates the coordinates for the iframe:x = 0 - (-500) = 500. It sends{x: 500, y: 500}to the cross-origin iframe. - 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.