CVE-2026-113243
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
CaretPositionTestthird_party/blink/renderer/core/css/cssom/caret_position_test.cc |
modified | |
ifthird_party/blink/renderer/core/css/cssom/caret_position_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/css/cssom/caret_position_test.cc |
modified | |
forthird_party/blink/web_tests/fast/forms/suggested-value-caret-position-from-point.html |
modified |
Files Changed
third_party/blink/renderer/core/html/resources/html.cssthird_party/blink/renderer/core/css/cssom/caret_position_test.ccthird_party/blink/renderer/core/dom/document.ccthird_party/blink/web_tests/fast/forms/suggested-value-caret-position-from-point.html
Patch
From 69ae35fad00ad5f3c92ebd80c627a7c6af4c5c13 Mon Sep 17 00:00:00 2001 From: David Baron <[email protected]> Date: Tue, 15 Sep 2026 09:35:40 -0700 Subject: [PATCH] Make autofill suggestion previews pointer-events:none. This sets pointer-events:none on autofill suggestion previews so that the suggested text cannot be computed using caretPositionFromPoint. Everything except the document.cc changes are the AI-suggested fix; I wrote the document.cc changes to replace an AI-suggested duplicate fix with a CHECK(). I verified that both the unittest and the web test fail without the html.css change. Fixed: 553147798 Change-Id: Ia6e91f59a0cca3926aaa09442446d1b4c79d2339 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8392201 Commit-Queue: David Baron <[email protected]> Reviewed-by: Joey Arhar <[email protected]> Cr-Commit-Position: refs/heads/main@{#1698172} --- diff --git a/third_party/blink/renderer/core/dom/document.cc b/third_party/blink/renderer/core/dom/document.cc index 2f00ea9..163385c 100644 --- a/third_party/blink/renderer/core/dom/document.cc +++ b/third_party/blink/renderer/core/dom/document.cc @@ -2090,6 +2090,10 @@ Node* anchor_node = position_with_affinity.AnchorNode(); if (TextControlElement* text_control = EnclosingTextControl(anchor_node)) { anchor_node = text_control; + CHECK(anchor_node == text_control || + text_control->InnerEditorElement()->contains(anchor_node)) + << "We should not expose positions in content (e.g., autofill " + "suggestions) not in the editor"; } bool adjust_position = false; while (anchor_node->IsInShadowTree() && diff --git a/third_party/blink/renderer/core/html/resources/html.css b/third_party/blink/renderer/core/html/resources/html.css index 6167ab3..c4adff7 100644 --- a/third_party/blink/renderer/core/html/resources/html.css +++ b/third_party/blink/renderer/core/html/resources/html.css @@ -634,6 +634,11 @@ overflow: hidden; } +input::-internal-input-suggested, +textarea::-internal-input-suggested { + pointer-events: none !important; +} + @supports blink-feature(AutofillPreviewIgnoreAuthorFont) { input::-internal-input-suggested, textarea::-internal-input-suggested { diff --git a/third_party/blink/renderer/core/css/cssom/caret_position_test.cc b/third_party/blink/renderer/core/css/cssom/caret_position_test.cc index aff73dba..5c702a50 100644 --- a/third_party/blink/renderer/core/css/cssom/caret_position_test.cc +++ b/third_party/blink/renderer/core/css/cssom/caret_position_test.cc @@ -5,6 +5,7 @@ #include "third_party/blink/renderer/core/css/cssom/caret_position.h" #include "testing/gtest/include/gtest/gtest.h" +#include "third_party/blink/renderer/bindings/core/v8/v8_caret_position_from_point_options.h" #include "third_party/blink/renderer/core/dom/range.h" #include "third_party/blink/renderer/core/geometry/dom_rect.h" #include "third_party/blink/renderer/core/html/forms/text_control_element.h" @@ -16,6 +17,33 @@ class CaretPositionTest : public PageTestBase { public: CaretPositionTest() = default; + + protected: + // Sweeps caretPositionFromPoint() across the width of |control| and returns + // the distinct getClientRect() positions of the reported carets. + Vector<gfx::PointF> DistinctCaretRectPositions(TextControlElement& control) { + const DOMRect* bounds = control.GetBoundingClientRect(); + const float y = bounds->top() + 12; + Vector<gfx::PointF> positions; + for (float x = bounds->left() + 4; x <= bounds->right() - 4; x += 2) { + CaretPosition* caret_position = GetDocument().caretPositionFromPoint( + x, y, CaretPositionFromPointOptions::Create()); + if (!caret_position) { + continue; + } + EXPECT_EQ(&control, caret_position->offsetNode()); + EXPECT_EQ(0u, caret_position->offset()); + DOMRect* rect = caret_position->getClientRect(); + if (!rect) { + continue; + } + gfx::PointF position(rect->x(), rect->y()); + if (!positions.Contains(position)) { + positions.push_back(position); + } + } + return positions; + } }; TEST_F(CaretPositionTest, offsetNodeAndOffset) { @@ -117,4 +145,25 @@ EXPECT_NE(nullptr, caret_position_client_rect); EXPECT_EQ(*range_client_rect, *caret_position_client_rect); } + +TEST_F(CaretPositionTest, caretPositionFromPointInInputWithSuggestedValue) { + SetBodyContent("<input style='width: 300px; font-size: 16px'>"); + auto& input = + ToTextControl(*GetDocument().QuerySelector(AtomicString("input"))); + input.SetSuggestedValue("suggested value 0123456789"); + UpdateAllLifecyclePhasesForTest(); + + EXPECT_LE(DistinctCaretRectPositions(input).size(), 1u); +} + +TEST_F(CaretPositionTest, caretPositionFromPointInTextareaWithSuggestedValue) { + SetBodyContent( + "<textarea style='width: 300px; font-size: 16px' rows='2'></textarea>"); + auto& textarea = + ToTextControl(*GetDocument().QuerySelector(AtomicString("textarea"))); + textarea.SetSuggestedValue("suggested value 0123456789"); + UpdateAllLifecyclePhasesForTest(); + + EXPECT_LE(DistinctCaretRectPositions(textarea).size(), 1u); +} } // namespace blink diff --git a/third_party/blink/web_tests/fast/forms/suggested-value-caret-position-from-point.html b/third_party/blink/web_tests/fast/forms/suggested-value-caret-position-from-point.html new file mode 100644 index 0000000..f3fa8fc --- /dev/null +++ b/third_party/blink/web_tests/fast/forms/suggested-value-caret-position-from-point.html @@ -0,0 +1,95 @@ +<!DOCTYPE html> +<link rel="help" href="https://drafts.csswg.org/cssom-view/#dom-document-caretpositionfrompoint"> +<script src="../../resources/testharness.js"></script> +<script src="../../resources/testharnessreport.js"></script> +<style> + input, textarea { + width: 300px; + font-size: 16px; + font-family: monospace; + } +</style> +<input id="empty-input"> +<input id="filled-input" value="ab"> +<input id="password-input" type="password"> +<textarea id="text-area" rows="2"></textarea> +<input id="placeholder-input" placeholder="placeholder text 0123456789"> +<input id="value-input" value="mm mm mm mm mm mm mm"> +<script> +const suggestion = 'suggested value 0123456789'; + +// Sweeps caretPositionFromPoint() across the width of |control| and returns +// the set of distinct getClientRect() positions of the reported carets. +function distinctCaretRectPositions(control) { + const bounds = control.getBoundingClientRect(); + const y = bounds.top + 12; + const positions = new Set(); + for (let x = bounds.left + 4; x <= bounds.right - 4; x += 2) { + const caretPosition = document.caretPositionFromPoint(x, y); + if (!caretPosition) + continue; + assert_equals(caretPosition.offsetNode, control, + `offsetNode at (${x}, ${y})`); + assert_less_than_equal(caretPosition.offset, control.value.length, + `offset at (${x}, ${y})`); + const rect = caretPosition.getClientRect(); + if (rect) + positions.add(`${rect.x},${rect.y}`); + } + return positions; +} + +function testSuggestedValue(control, maxDistinctPositions, description) { + test(() => { + control.focus(); + internals.setSuggestedValue(control, suggestion); + try { + const positions = distinctCaretRectPositions(control); + assert_less_than_equal( + positions.size, maxDistinctPositions, + `distinct caret rects: ${JSON.stringify([...positions])}`); + } finally { + internals.setSuggestedValue(control, ''); + } + }, description); +} + +testSuggestedValue( + document.getElementById('empty-input'), 1, + 'caretPositionFromPoint() over an empty <input> showing a suggested ' + + 'value maps every point to the caret at the start of the value'); + +testSuggestedValue( + document.getElementById('filled-input'), + document.getElementById('filled-input').value.length + 1, + 'caretPositionFromPoint() over an <input> with a value showing a ' + + 'suggested value only reports carets within the value'); + +testSuggestedValue( + document.getElementById('password-input'), 1, + 'caretPositionFromPoint() over an empty password <input> showing a ' + + 'suggested value maps every point to the caret at the start of the ' + + 'value'); + +testSuggestedValue( + document.getElementById('text-area'), 1, + 'caretPositionFromPoint() over an empty <textarea> showing a suggested ' + + 'value maps every point to the caret at the start of the value'); + +test(() => { + const control = document.getElementById('placeholder-input'); + const positions = distinctCaretRectPositions(control); + assert_less_than_equal( + positions.size, 1, + `distinct caret rects: ${JSON.stringify([...positions])}`); +}, 'caretPositionFromPoint() over an empty <input> showing a placeholder ' + + 'maps every point to the caret at the start of the value'); + +test(() => { + const control = document.getElementById('value-input'); + const positions = distinctCaretRectPositions(control); + assert_greater_than(positions.size, 1, + 'caret rects should track positions within the value'); +}, 'caretPositionFromPoint() over an <input> with a value reports carets ' + + 'at each position within the value'); +</script>
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/css/cssom/caret_position_test.cc b/third_party/blink/renderer/core/css/cssom/caret_position_test.cc
index aff73dba..5c702a50 100644
--- a/third_party/blink/renderer/core/css/cssom/caret_position_test.cc
+++ b/third_party/blink/renderer/core/css/cssom/caret_position_test.cc
@@ -5,6 +5,7 @@
#include "third_party/blink/renderer/core/css/cssom/caret_position.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_caret_position_from_point_options.h"
#include "third_party/blink/renderer/core/dom/range.h"
#include "third_party/blink/renderer/core/geometry/dom_rect.h"
#include "third_party/blink/renderer/core/html/forms/text_control_element.h"
@@ -16,6 +17,33 @@
class CaretPositionTest : public PageTestBase {
public:
CaretPositionTest() = default;
+
+ protected:
+ // Sweeps caretPositionFromPoint() across the width of |control| and returns
+ // the distinct getClientRect() positions of the reported carets.
+ Vector<gfx::PointF> DistinctCaretRectPositions(TextControlElement& control) {
+ const DOMRect* bounds = control.GetBoundingClientRect();
+ const float y = bounds->top() + 12;
+ Vector<gfx::PointF> positions;
+ for (float x = bounds->left() + 4; x <= bounds->right() - 4; x += 2) {
+ CaretPosition* caret_position = GetDocument().caretPositionFromPoint(
+ x, y, CaretPositionFromPointOptions::Create());
+ if (!caret_position) {
+ continue;
+ }
+ EXPECT_EQ(&control, caret_position->offsetNode());
+ EXPECT_EQ(0u, caret_position->offset());
+ DOMRect* rect = caret_position->getClientRect();
+ if (!rect) {
+ continue;
+ }
+ gfx::PointF position(rect->x(), rect->y());
+ if (!positions.Contains(position)) {
+ positions.push_back(position);
+ }
+ }
+ return positions;
+ }
};
TEST_F(CaretPositionTest, offsetNodeAndOffset) {
@@ -117,4 +145,25 @@
EXPECT_NE(nullptr, caret_position_client_rect);
EXPECT_EQ(*range_client_rect, *caret_position_client_rect);
}
+
+TEST_F(CaretPositionTest, caretPositionFromPointInInputWithSuggestedValue) {
+ SetBodyContent("<input style='width: 300px; font-size: 16px'>");
+ auto& input =
+ ToTextControl(*GetDocument().QuerySelector(AtomicString("input")));
+ input.SetSuggestedValue("suggested value 0123456789");
+ UpdateAllLifecyclePhasesForTest();
+
+ EXPECT_LE(DistinctCaretRectPositions(input).size(), 1u);
+}
+
+TEST_F(CaretPositionTest, caretPositionFromPointInTextareaWithSuggestedValue) {
+ SetBodyContent(
+ "<textarea style='width: 300px; font-size: 16px' rows='2'></textarea>");
+ auto& textarea =
+ ToTextControl(*GetDocument().QuerySelector(AtomicString("textarea")));
+ textarea.SetSuggestedValue("suggested value 0123456789");
+ UpdateAllLifecyclePhasesForTest();
+
+ EXPECT_LE(DistinctCaretRectPositions(textarea).size(), 1u);
+}
} // namespace blink
diff --git a/third_party/blink/web_tests/fast/forms/suggested-value-caret-position-from-point.html b/third_party/blink/web_tests/fast/forms/suggested-value-caret-position-from-point.html
new file mode 100644
index 0000000..f3fa8fc
--- /dev/null
+++ b/third_party/blink/web_tests/fast/forms/suggested-value-caret-position-from-point.html
@@ -0,0 +1,95 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/cssom-view/#dom-document-caretpositionfrompoint">
+<script src="../../resources/testharness.js"></script>
+<script src="../../resources/testharnessreport.js"></script>
+<style>
+ input, textarea {
+ width: 300px;
+ font-size: 16px;
+ font-family: monospace;
+ }
+</style>
+<input id="empty-input">
+<input id="filled-input" value="ab">
+<input id="password-input" type="password">
+<textarea id="text-area" rows="2"></textarea>
+<input id="placeholder-input" placeholder="placeholder text 0123456789">
+<input id="value-input" value="mm mm mm mm mm mm mm">
+<script>
+const suggestion = 'suggested value 0123456789';
+
+// Sweeps caretPositionFromPoint() across the width of |control| and returns
+// the set of distinct getClientRect() positions of the reported carets.
+function distinctCaretRectPositions(control) {
+ const bounds = control.getBoundingClientRect();
+ const y = bounds.top + 12;
+ const positions = new Set();
+ for (let x = bounds.left + 4; x <= bounds.right - 4; x += 2) {
+ const caretPosition = document.caretPositionFromPoint(x, y);
+ if (!caretPosition)
+ continue;
+ assert_equals(caretPosition.offsetNode, control,
+ `offsetNode at (${x}, ${y})`);
+ assert_less_than_equal(caretPosition.offset, control.value.length,
+ `offset at (${x}, ${y})`);
+ const rect = caretPosition.getClientRect();
+ if (rect)
+ positions.add(`${rect.x},${rect.y}`);
+ }
+ return positions;
+}
+
+function testSuggestedValue(control, maxDistinctPositions, description) {
+ test(() => {
+ control.focus();
+ internals.setSuggestedValue(control, suggestion);
+ try {
+ const positions = distinctCaretRectPositions(control);
+ assert_less_than_equal(
+ positions.size, maxDistinctPositions,
+ `distinct caret rects: ${JSON.stringify([...positions])}`);
+ } finally {
+ internals.setSuggestedValue(control, '');
+ }
+ }, description);
+}
+
+testSuggestedValue(
+ document.getElementById('empty-input'), 1,
+ 'caretPositionFromPoint() over an empty <input> showing a suggested ' +
+ 'value maps every point to the caret at the start of the value');
+
+testSuggestedValue(
+ document.getElementById('filled-input'),
+ document.getElementById('filled-input').value.length + 1,
+ 'caretPositionFromPoint() over an <input> with a value showing a ' +
+ 'suggested value only reports carets within the value');
+
+testSuggestedValue(
+ document.getElementById('password-input'), 1,
+ 'caretPositionFromPoint() over an empty password <input> showing a ' +
+ 'suggested value maps every point to the caret at the start of the ' +
+ 'value');
+
+testSuggestedValue(
+ document.getElementById('text-area'), 1,
+ 'caretPositionFromPoint() over an empty <textarea> showing a suggested ' +
+ 'value maps every point to the caret at the start of the value');
+
+test(() => {
+ const control = document.getElementById('placeholder-input');
+ const positions = distinctCaretRectPositions(control);
+ assert_less_than_equal(
+ positions.size, 1,
+ `distinct caret rects: ${JSON.stringify([...positions])}`);
+}, 'caretPositionFromPoint() over an empty <input> showing a placeholder ' +
+ 'maps every point to the caret at the start of the value');
+
+test(() => {
+ const control = document.getElementById('value-input');
+ const positions = distinctCaretRectPositions(control);
+ assert_greater_than(positions.size, 1,
+ 'caret rects should track positions within the value');
+}, 'caretPositionFromPoint() over an <input> with a value reports carets ' +
+ 'at each position within the value');
+</script>
Original Bug Report
Autofill preview PII leak via document.caretPositionFromPoint().getClientRect()
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: Document::caretPositionFromPoint() fails to properly adjust positions inside text control UA shadow trees when the control is in the light DOM, retaining the raw UA-shadow position. Furthermore, the Autofill preview pseudo-element (::-internal-input-suggested) lacks a pointer-events: none rule, allowing the previewed text node to be hit-tested. Consequently, CaretPosition::getClientRect() constructs a collapsed Range on the suggested text node, potentially leaking character advance widths and coordinates of Autofill PII prior to user submission.
Affected files:
third_party/blink/renderer/core/dom/document.ccthird_party/blink/renderer/core/css/cssom/caret_position.ccthird_party/blink/renderer/core/html/resources/html.css
Estimated timestamp from git blame: 2024-05-15
Summary
A potential information disclosure vulnerability exists in Blink where document.caretPositionFromPoint().getClientRect() can be used to extract character advance widths and geometry of Autofill preview strings (PII) before the user submits or accepts the Autofill suggestion.
Root Cause Analysis
The issue arises from the interaction of two implementation details:
- Unadjusted Raw Shadow Position in
Document::caretPositionFromPoint(third_party/blink/renderer/core/dom/document.cc:2090-2106):
Node* anchor_node = position_with_affinity.AnchorNode();
if (TextControlElement* text_control = EnclosingTextControl(anchor_node)) {
anchor_node = text_control;
}
bool adjust_position = false;
while (anchor_node->IsInShadowTree() &&
!(options->hasShadowRoots() &&
options->shadowRoots().Contains(anchor_node->GetTreeScope()))) {
anchor_node = anchor_node->OwnerShadowHost();
adjust_position = true;
}
Position adjusted_position = adjust_position
? Position::InParentBeforeNode(*anchor_node)
: position_with_affinity.GetPosition();
CHECK(!adjusted_position.IsNull());
return CreateCaretPosition(adjusted_position.ParentAnchoredEquivalent());
When hit-testing hits a node inside a text control’s UA shadow tree, EnclosingTextControl(anchor_node) returns the host <input> or <textarea>. However, if the control is in the light DOM, anchor_node->IsInShadowTree() evaluates to false. As a result, adjust_position remains false, and adjusted_position retains position_with_affinity.GetPosition() (the raw UA-shadow position). CreateCaretPosition (document.cc:1172-1175) then stores this raw node and offset in the CaretPosition object.
- Unsanitized Range Construction in
CaretPosition::getClientRect()(third_party/blink/renderer/core/css/cssom/caret_position.cc:35-42):
DOMRect* CaretPosition::getClientRect() const {
if (!node_) {
return nullptr;
}
auto* range_object = MakeGarbageCollected<Range>(node_->GetDocument(), node_,
offset_, node_, offset_);
return range_object->getBoundingClientRect();
}
While CaretPosition::offsetNode() and CaretPosition::offset() sanitize their outputs for text controls, getClientRect() constructs a collapsed Range directly on node_ and offset_. For collapsed ranges inside layout text, Range::getBoundingClientRect() returns a zero-width bounding box positioned at the character boundary (third_party/blink/renderer/core/layout/layout_text.cc:638-646, third_party/blink/renderer/core/dom/range.cc:1828-1830).
- Missing
pointer-events: noneon Suggested Values (third_party/blink/renderer/core/html/resources/html.css): While::-webkit-input-placeholderdefinespointer-events: none !important;(html.css:617),::-internal-input-suggested(html.css:631-725) omits this rule. When an Autofill suggestion is previewed on hover,TextControlElement::SetSuggestedValueswaps the placeholder’s pseudo-element ID to::-internal-input-suggested(third_party/blink/renderer/core/html/forms/text_control_element.cc:319-330), rendering the suggested PII text hit-testable.
Potential Attack Steps
(Note: These are suggested/potential steps derived from static code analysis; our tooling agent does not have code execution capabilities to verify dynamic runtime execution.)
- The attacker serves a webpage with an input field (e.g.
<input type="text" name="name" autocomplete="name">) and runs a measurement script inrequestAnimationFrame. - The user focuses the input, triggering Chrome’s Autofill popup, and hovers over a profile suggestion.
- The browser process previews the suggestion in the renderer, populating the UA shadow DOM placeholder with the suggested PII text and pseudo-element
::-internal-input-suggested. - Attacker JavaScript calls
document.caretPositionFromPoint(x, y)across the input’s bounding box. - Because the suggested text node is hit-tested and the raw UA-shadow position is preserved,
caretPosition.getClientRect()returns the precisexcoordinates of each character boundary in CSS viewport pixels. - By measuring the difference between consecutive boundary positions ($X_{k+1} - X_k$), the attacker obtains the exact advance width of every character rendered in the UA-pinned font (
font-family: sans-serif !important), enabling reconstruction of the PII string against dictionary candidates prior to user submission.
Suggested Fix
- In
third_party/blink/renderer/core/html/resources/html.css, addpointer-events: none !important;toinput::-internal-input-suggestedandtextarea::-internal-input-suggested. - In
third_party/blink/renderer/core/dom/document.cc(Document::caretPositionFromPoint), ensure positions inside text controls are properly mapped to light DOM equivalents (or host-relative positions) rather than preserving the raw UA-shadow position whenadjust_positionis evaluated. - Alternatively, update
CaretPosition::getClientRect()(third_party/blink/renderer/core/css/cssom/caret_position.cc:35-42) to use the remapped position ifnode_belongs to a text control UA shadow tree.
Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c
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.