CVE-2026-76026
Overview
Background
- ONE_TIME_CODE (OTP) autofill
- A dedicated Autofill integration (
components/autofill/core/browser/integrators/one_time_tokens/) that retrieves SMS/one-time tokens from a backend service and offers them as fill suggestions on fields classified asONE_TIME_CODE. - `OtpFieldDetector`
- Utility that decides eligibility:
IsOtpForm(FormStructure)answers “does this form qualify for OTP filling”, and (new in this patch)IsOtpField(FormStructure, field)answers “is this specific field a valid OTP target”. - `FormStructure` / `AutofillField` / `FormFieldData`
- Server/heuristic-classified form (
FormStructure, holdingAutofillFields) vs. the raw renderer-supplied field (FormFieldData). Each field carries anorigin(); the form carriesmain_frame_origin(). - Same-site check
net::registry_controlled_domains::SameDomainOrHost(field.origin(), form.main_frame_origin(), ...)gates whether a field is considered to belong to the top frame.- `OtpManagerImpl::GetOtpSuggestions`
- Entry point invoked when a field is focused; it decides whether to fetch and return the real OTP value to the renderer for that field.
Root Cause Analysis
The pre-patch eligibility logic applied the same-site origin check only at the form level and never re-validated the triggering field. OtpManagerImpl::GetOtpSuggestions (and the legacy impl) gated solely on field.origin().opaque() plus OtpFieldDetector::IsOtpForm(form). IsOtpForm iterated the fields and, for each ONE_TIME_CODE field, returned false only if that field was cross-site; otherwise it set has_otp_field = true. Crucially, once the form was deemed an “OTP form”, the manager would proceed to fetch the OTP and the per-field loops (otp_field_ids, GetTargetField, IsOtpDeliveryBlocked) collected every field whose type merely contains(ONE_TIME_CODE) — with no check that the individual field was focusable, non-password, or same-site with the main frame. A form could therefore contain one legitimate same-origin OTP field (making IsOtpForm true) alongside a password-typed or cross-origin ONE_TIME_CODE field, and the manager would treat that second field as a valid fill/target, exposing the one-time token to it.
The patch centralizes field-level eligibility in the new OtpFieldDetector::IsOtpField, which requires ONE_TIME_CODE type and HasEligibleOtpFieldProperties (focusable, not a password input, same-domain-or-host as the main frame). It then (1) validates the triggering field in GetOtpSuggestions with !OtpFieldDetector::IsOtpField(form, field) before any fetch, (2) replaces the bare contains(ONE_TIME_CODE) tests in the otp_field_ids collection, GetTargetField, and IsOtpDeliveryBlocked with IsOtpField, and (3) tightens IsOtpForm so the cross-site check applies to all ONE_TIME_CODE fields (including password ones) before counting any eligible field. The new unit tests (CrossOriginPasswordFieldAndSameOriginTextOtpField, GetOtpSuggestionsPasswordFieldInMixedFormReturnsEmpty) confirm a password/cross-origin ONE_TIME_CODE field no longer qualifies even when a same-origin text OTP field is present.
The advisory labels this “Memory corruption in Autofill,” but the diff contains no memory-safety sink whatsoever — it is purely an origin/field eligibility enforcement change. The substantiated bug class is a cross-origin disclosure / policy bypass of a one-time token, not memory corruption.
ONE_TIME_CODE field in it, including a cross-origin or password field an attacker planted.Attack Path
- Embed a mixed form
Attacker on
attacker.com(or script injected into a page) constructs a form whose main-frame origin passes the form-level test via one benign same-originONE_TIME_CODEtext field, and adds a secondONE_TIME_CODEfield controlled by the attacker (cross-origin subframe field, or a password-typed input) (inferred, from the test scenarios the patch adds). - Trigger OTP flow
Induce focus / suggestion on the attacker-controlled
ONE_TIME_CODEfield soGetOtpSuggestionsruns; pre-patch it passed because onlyIsOtpFormandfield.origin().opaque()were checked. - OTP fetched and targeted
The manager fetches the real one-time token and, via the
otp_field_ids/GetTargetFieldloops that matched on barecontains(ONE_TIME_CODE), treats the attacker field as a valid fill target. - Exfiltrate token The SMS/one-time code is surfaced to / fillable into the attacker-controlled field, letting the attacker read the victim’s OTP (inferred reachability; the diff proves the eligibility gap, not a specific exploit page).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
AutofillClientcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h |
modified | |
AutofillFieldcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h |
modified | |
FormFieldDatacomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h |
modified | |
FormStructurecomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h |
modified | |
ifcomponents/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc |
modified | |
TESTcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc |
modified |
Files Changed
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cccomponents/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cccomponents/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl_unittest.cccomponents/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl.cccomponents/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cccomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.hcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
Audit Directions
- Form-level vs. field-level gatesAudit other Autofill integrators (passwords, payment, addresses) for places that decide eligibility over a whole
FormStructurebut then act on individual fields matched only by type, without re-checking origin/focusability. - Origin checks on sensitive fillReview every path that returns a secret (OTP, stored credential) to the renderer for a per-field
SameDomainOrHost/CanAccesscheck against the main frame origin, especially where opaque-origin is the only guard. - Cross-frame field aggregationInspect loops that enumerate
form->fields()across host frames (host_frame,global_id) to ensure subframe-origin fields cannot be smuggled into a top-frame OTP/credential decision.
Patch
From 4b47de55fa514cf90042b65c634b6ac10502d42b Mon Sep 17 00:00:00 2001
From: Matthias Körber <koerber@google.com>
Date: Thu, 08 Oct 2026 02:41:42 -0700
Subject: [PATCH] [OTP] Enforce field-level and form-level OTP eligibility checks
Introduce OtpFieldDetector::IsOtpField to centralize field-level OTP
eligibility (focusable, non-password ONE_TIME_CODE field that is
same-site with the main frame origin).
Update OtpFieldDetector::IsOtpForm to verify same-site origin matching
across all ONE_TIME_CODE fields in a form before filtering for focusable
non-password fields, and validate the triggering field in
OtpManagerImpl::GetOtpSuggestions.
TAG=agy
CONV=516b42cd-4943-47d9-84dc-eab47cd1b300
Bug: 567673200
Change-Id: I12fcef341c0a94559bce0e2a725a0a0dfcd1d044
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8499574
Auto-Submit: Matthias Körber <koerber@google.com>
Reviewed-by: Maria Kazinova <kazinova@google.com>
Commit-Queue: Matthias Körber <koerber@google.com>
Cr-Commit-Position: refs/heads/main@{#1714312}
---
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
index ee8bb2b2..93f4e4d 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
@@ -14,28 +14,64 @@
#include "components/autofill/core/browser/form_structure.h"
#include "components/autofill/core/browser/foundations/autofill_client.h"
#include "components/autofill/core/browser/foundations/autofill_driver.h"
+#include "components/autofill/core/common/form_field_data.h"
#include "components/autofill/core/common/unique_ids.h"
#include "net/base/registry_controlled_domains/registry_controlled_domain.h"
namespace autofill {
+namespace {
+
+bool IsSameDomainOrHostAsMainFrame(const FormStructure& form,
+ const FormFieldData& field) {
+ return net::registry_controlled_domains::SameDomainOrHost(
+ field.origin(), form.main_frame_origin(),
+ net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
+}
+
+bool HasEligibleOtpFieldProperties(const FormStructure& form,
+ const FormFieldData& field) {
+ return field.is_focusable() && !field.IsPasswordInputElement() &&
+ IsSameDomainOrHostAsMainFrame(form, field);
+}
+
+} // namespace
+
+// static
+bool OtpFieldDetector::IsOtpField(const FormStructure& form,
+ const AutofillField& field) {
+ return field.Type().GetTypes().contains(ONE_TIME_CODE) &&
+ HasEligibleOtpFieldProperties(form, field);
+}
+
+// static
+bool OtpFieldDetector::IsOtpField(const FormStructure& form,
+ const FormFieldData& field) {
+ const AutofillField* autofill_field = form.GetFieldById(field.global_id());
+ if (!autofill_field || !IsOtpField(form, *autofill_field)) {
+ return false;
+ }
+ return field.is_focusable() && !field.IsPasswordInputElement() &&
+ (field.origin() == autofill_field->origin() ||
+ IsSameDomainOrHostAsMainFrame(form, field));
+}
+
+// static
bool OtpFieldDetector::IsOtpForm(const FormStructure& form) {
bool has_otp_field = false;
for (const std::unique_ptr<AutofillField>& f : form.fields()) {
- if (!f->Type().GetTypes().contains(ONE_TIME_CODE) || !f->is_focusable() ||
- f->IsPasswordInputElement()) {
+ if (!f->Type().GetTypes().contains(ONE_TIME_CODE)) {
continue;
}
- has_otp_field = true;
-
- if (!net::registry_controlled_domains::SameDomainOrHost(
- f->origin(), form.main_frame_origin(),
- net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES)) {
+ if (!IsSameDomainOrHostAsMainFrame(form, *f)) {
// TODO(crbug.com/441433533): Consider making this less strict by
// introducing a field-level check in the manager instead of dropping
// the entire form.
return false;
}
+ if (!has_otp_field && IsOtpField(form, *f)) {
+ has_otp_field = true;
+ }
}
return has_otp_field;
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h
index dea65b3..24212d3 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h
@@ -16,6 +16,8 @@
namespace autofill {
class AutofillClient;
+class AutofillField;
+class FormFieldData;
class FormStructure;
// Utility class to observe if a WebContents (with all of its frames)
@@ -57,9 +59,16 @@
// one OTP field.
bool IsOtpFieldPresent() const;
+ // Returns true if `field` is a focusable `ONE_TIME_CODE` field in `form` that
+ // is not a password input and is same-site with the main frame's origin.
+ [[nodiscard]] static bool IsOtpField(const FormStructure& form,
+ const AutofillField& field);
+ [[nodiscard]] static bool IsOtpField(const FormStructure& form,
+ const FormFieldData& field);
+
// Returns true if the `form` contains at least one focusable `ONE_TIME_CODE`
- // field that is not a password input, and all focusable `ONE_TIME_CODE`
- // fields in the `form` are same-site with the main frame's origin.
+ // field that is not a password input, and all `ONE_TIME_CODE` fields in the
+ // `form` are same-site with the main frame's origin.
[[nodiscard]] static bool IsOtpForm(const FormStructure& form);
// AutofillManager::Observer:
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
index c36bc1a..f434aea6 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
@@ -99,7 +99,8 @@
const FormFieldData& field,
OtpManagerImpl::GetOtpSuggestionsCallback callback) {
if (!one_time_token_service_ || owner_->driver().IsEmbedded() ||
- field.origin().opaque() || !OtpFieldDetector::IsOtpForm(form)) {
+ !OtpFieldDetector::IsOtpField(form, field) ||
+ !OtpFieldDetector::IsOtpForm(form)) {
std::move(callback).Run({});
return;
}
@@ -249,7 +250,7 @@
std::vector<FieldGlobalId> otp_field_ids;
for (const auto& field : form->fields()) {
- if (field->Type().GetTypes().contains(ONE_TIME_CODE)) {
+ if (OtpFieldDetector::IsOtpField(*form, *field)) {
otp_field_ids.push_back(field->global_id());
}
}
@@ -607,24 +608,25 @@
if (!currently_focused_field_id_.has_value()) {
return nullptr;
}
+ const FormStructure* form = nullptr;
const AutofillField* field = nullptr;
if (currently_focused_form_id_.has_value()) {
- field = owner_
- ->FindFormAndField(*currently_focused_form_id_,
- *currently_focused_field_id_)
- .autofill_field;
+ AutofillManager::FormAndField form_and_field = owner_->FindFormAndField(
+ *currently_focused_form_id_, *currently_focused_field_id_);
+ form = form_and_field.form_structure;
+ field = form_and_field.autofill_field;
}
if (!field) {
// AutofillManager provides an overload `FindCachedFormById(const
// FieldGlobalId&)` that searches cached forms for the one containing the
// given field ID.
- if (const FormStructure* form =
- owner_->FindCachedFormById(*currently_focused_field_id_)) {
+ form = owner_->FindCachedFormById(*currently_focused_field_id_);
+ if (form) {
field = form->GetFieldById(*currently_focused_field_id_);
}
}
- return field && field->Type().GetTypes().contains(ONE_TIME_CODE) ? field
- : nullptr;
+ return form && field && OtpFieldDetector::IsOtpField(*form, *field) ? field
+ : nullptr;
}
bool OtpManagerImpl::IsOtpDeliveryBlocked() {
@@ -642,10 +644,11 @@
if (has_typed_input) {
return;
}
- has_typed_input = std::ranges::any_of(form.fields(), [](const auto& field) {
- return field->Type().GetTypes().contains(ONE_TIME_CODE) &&
- field->all_modifiers().contains(FieldModifier::kUser);
- });
+ has_typed_input =
+ std::ranges::any_of(form.fields(), [&form](const auto& field) {
+ return OtpFieldDetector::IsOtpField(form, *field) &&
+ field->all_modifiers().contains(FieldModifier::kUser);
+ });
});
return has_typed_input;
}
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl.cc
index 617cc17..ac3db02 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl.cc
@@ -73,7 +73,8 @@
const FormFieldData& field,
OtpManagerLegacyImpl::GetOtpSuggestionsCallback callback) {
if (!one_time_token_service_ || owner_->driver().IsEmbedded() ||
- field.origin().opaque() || !OtpFieldDetector::IsOtpForm(form)) {
+ !OtpFieldDetector::IsOtpField(form, field) ||
+ !OtpFieldDetector::IsOtpForm(form)) {
std::move(callback).Run({});
return;
}
@@ -158,7 +159,7 @@
std::vector<FieldGlobalId> otp_field_ids;
for (const auto& field : form->fields()) {
- if (field->Type().GetTypes().contains(ONE_TIME_CODE)) {
+ if (OtpFieldDetector::IsOtpField(*form, *field)) {
otp_field_ids.push_back(field->global_id());
}
}
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
index 50d2ec94..b754977 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
@@ -542,7 +542,8 @@
}
// Tests that `IsOtpForm` returns true when there is both a password field
-// and a text-based OTP field.
+// and a text-based OTP field on the same origin, while `IsOtpField` only
+// returns true for the text-based OTP field.
TEST(OtpFieldDetectorIsOtpFormTest, PasswordFieldAndTextOtpField) {
FormData form;
form.set_main_frame_origin(url::Origin::Create(GURL("https://example.com")));
@@ -562,6 +563,66 @@
form_structure.field(1)->SetTypeTo(AutofillType(ONE_TIME_CODE), std::nullopt);
EXPECT_TRUE(OtpFieldDetector::IsOtpForm(form_structure));
+ EXPECT_FALSE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(0)));
+ EXPECT_TRUE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(1)));
+}
+
+// Tests that `IsOtpForm` returns false when a cross-origin password field with
+// `ONE_TIME_CODE` is present alongside a same-origin text OTP field.
+TEST(OtpFieldDetectorIsOtpFormTest,
+ CrossOriginPasswordFieldAndSameOriginTextOtpField) {
+ FormData form;
+ form.set_main_frame_origin(url::Origin::Create(GURL("https://example.com")));
+ FormFieldData field1;
+ field1.set_origin(url::Origin::Create(GURL("https://attacker.com")));
+ field1.set_is_focusable(true);
+ field1.set_form_control_type(FormControlType::kInputPassword);
+ FormFieldData field2;
+ field2.set_origin(url::Origin::Create(GURL("https://example.com")));
+ field2.set_is_focusable(true);
+ field2.set_form_control_type(FormControlType::kInputText);
+ form.set_fields({field1, field2});
+
+ FormStructure form_structure(form);
+ ASSERT_EQ(form_structure.fields().size(), 2u);
+ form_structure.field(0)->SetTypeTo(AutofillType(ONE_TIME_CODE), std::nullopt);
+ form_structure.field(1)->SetTypeTo(AutofillType(ONE_TIME_CODE), std::nullopt);
+
+ EXPECT_FALSE(OtpFieldDetector::IsOtpForm(form_structure));
+ EXPECT_FALSE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(0)));
+ EXPECT_TRUE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(1)));
+}
+
+// Tests that `IsOtpForm` returns false when a cross-origin unfocusable field
+// with `ONE_TIME_CODE` is present alongside a same-origin text OTP field.
+TEST(OtpFieldDetectorIsOtpFormTest,
+ CrossOriginUnfocusableFieldAndSameOriginTextOtpField) {
+ FormData form;
+ form.set_main_frame_origin(url::Origin::Create(GURL("https://example.com")));
+ FormFieldData field1;
+ field1.set_origin(url::Origin::Create(GURL("https://attacker.com")));
+ field1.set_is_focusable(false);
+ field1.set_form_control_type(FormControlType::kInputText);
+ FormFieldData field2;
+ field2.set_origin(url::Origin::Create(GURL("https://example.com")));
+ field2.set_is_focusable(true);
+ field2.set_form_control_type(FormControlType::kInputText);
+ form.set_fields({field1, field2});
+
+ FormStructure form_structure(form);
+ ASSERT_EQ(form_structure.fields().size(), 2u);
+ form_structure.field(0)->SetTypeTo(AutofillType(ONE_TIME_CODE), std::nullopt);
+ form_structure.field(1)->SetTypeTo(AutofillType(ONE_TIME_CODE), std::nullopt);
+
+ EXPECT_FALSE(OtpFieldDetector::IsOtpForm(form_structure));
+ EXPECT_FALSE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(0)));
+ EXPECT_TRUE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(1)));
}
} // namespace autofill
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
index a4312a8..bff18640 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
@@ -155,7 +155,12 @@
&TestBrowserAutofillManager::Observer::OnFieldTypesDetermined, form_id,
TestBrowserAutofillManager::Observer::FieldTypeSource::kAutofillAiModel,
/*small_forms_were_parsed=*/false);
- return autofill_manager().FindCachedFormById(form_id);
+ const FormStructure* cached_form =
+ autofill_manager().FindCachedFormById(form_id);
+ if (cached_form && !cached_form->fields().empty()) {
+ test_field_ = *cached_form->field(0);
+ }
+ return cached_form;
}
const FormStructure* AddFormWithOtpField(
@@ -166,11 +171,13 @@
.fields =
{
{.server_type = ONE_TIME_CODE,
+ .host_frame = autofill_driver().GetFrameToken(),
.is_focusable = is_focusable,
.label = u"OTP",
.name = u"otp",
.origin = field_origin},
},
+ .host_frame = autofill_driver().GetFrameToken(),
.main_frame_origin = main_frame_origin,
};
return AddForm(form_description);
@@ -632,12 +639,17 @@
EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp)
.WillOnce(RunOnceCallback<0>(otp));
- const FormStructure* form = AddFormWithOtpField();
- ASSERT_TRUE(form);
-
LocalFrameToken subframe_token = test::MakeLocalFrameToken();
- FormFieldData field = *form->field(0);
- field.set_host_frame(subframe_token);
+ const FormStructure* form = AddForm({
+ .fields =
+ {
+ {.server_type = ONE_TIME_CODE,
+ .host_frame = subframe_token,
+ .label = u"OTP",
+ .name = u"otp"},
+ },
+ });
+ ASSERT_TRUE(form);
EXPECT_CALL(otp_phish_guard_delegate(), StartOtpPhishGuardCheck)
.WillOnce([&](LocalFrameToken frame_to_fill,
@@ -647,7 +659,7 @@
});
base::test::TestFuture<std::vector<one_time_tokens::OneTimeToken>> future;
- otp_manager.GetOtpSuggestions(*form, field, future.GetCallback());
+ otp_manager.GetOtpSuggestions(*form, *form->field(0), future.GetCallback());
ASSERT_EQ(future.Get().size(), 1u);
EXPECT_EQ(future.Get()[0].value(), otp.value());
@@ -1294,6 +1306,130 @@
EXPECT_TRUE(future.Get().empty());
}
+// Tests that in a same-origin form containing both a password `ONE_TIME_CODE`
+// field and a text `ONE_TIME_CODE` field, `GetOtpSuggestions` returns empty
+// suggestions when triggered on the password field, and returns the OTP when
+// triggered on the text field.
+TEST_F(OtpManagerImplTest,
+ GetOtpSuggestionsPasswordFieldInMixedFormReturnsEmpty) {
+ OtpManagerImpl otp_manager(autofill_manager(), &one_time_token_service_);
+
+ one_time_tokens::OneTimeToken otp(one_time_tokens::OneTimeTokenType::kSmsOtp,
+ kDefaultOtpValue, base::TimeTicks::Now());
+ EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp)
+ .WillOnce(RunOnceCallback<0>(otp));
+
+ const FormStructure* form = AddForm({
+ .fields =
+ {
+ {.server_type = ONE_TIME_CODE,
+ .is_focusable = true,
+ .label = u"Password OTP",
+ .name = u"pw_otp",
+ .form_control_type = FormControlType::kInputPassword},
+ {.server_type = ONE_TIME_CODE,
... (file diff truncated)
Regression Test / PoC
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
index 50d2ec94..b754977 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
@@ -542,7 +542,8 @@
}
// Tests that `IsOtpForm` returns true when there is both a password field
-// and a text-based OTP field.
+// and a text-based OTP field on the same origin, while `IsOtpField` only
+// returns true for the text-based OTP field.
TEST(OtpFieldDetectorIsOtpFormTest, PasswordFieldAndTextOtpField) {
FormData form;
form.set_main_frame_origin(url::Origin::Create(GURL("https://example.com")));
@@ -562,6 +563,66 @@
form_structure.field(1)->SetTypeTo(AutofillType(ONE_TIME_CODE), std::nullopt);
EXPECT_TRUE(OtpFieldDetector::IsOtpForm(form_structure));
+ EXPECT_FALSE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(0)));
+ EXPECT_TRUE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(1)));
+}
+
+// Tests that `IsOtpForm` returns false when a cross-origin password field with
+// `ONE_TIME_CODE` is present alongside a same-origin text OTP field.
+TEST(OtpFieldDetectorIsOtpFormTest,
+ CrossOriginPasswordFieldAndSameOriginTextOtpField) {
+ FormData form;
+ form.set_main_frame_origin(url::Origin::Create(GURL("https://example.com")));
+ FormFieldData field1;
+ field1.set_origin(url::Origin::Create(GURL("https://attacker.com")));
+ field1.set_is_focusable(true);
+ field1.set_form_control_type(FormControlType::kInputPassword);
+ FormFieldData field2;
+ field2.set_origin(url::Origin::Create(GURL("https://example.com")));
+ field2.set_is_focusable(true);
+ field2.set_form_control_type(FormControlType::kInputText);
+ form.set_fields({field1, field2});
+
+ FormStructure form_structure(form);
+ ASSERT_EQ(form_structure.fields().size(), 2u);
+ form_structure.field(0)->SetTypeTo(AutofillType(ONE_TIME_CODE), std::nullopt);
+ form_structure.field(1)->SetTypeTo(AutofillType(ONE_TIME_CODE), std::nullopt);
+
+ EXPECT_FALSE(OtpFieldDetector::IsOtpForm(form_structure));
+ EXPECT_FALSE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(0)));
+ EXPECT_TRUE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(1)));
+}
+
+// Tests that `IsOtpForm` returns false when a cross-origin unfocusable field
+// with `ONE_TIME_CODE` is present alongside a same-origin text OTP field.
+TEST(OtpFieldDetectorIsOtpFormTest,
+ CrossOriginUnfocusableFieldAndSameOriginTextOtpField) {
+ FormData form;
+ form.set_main_frame_origin(url::Origin::Create(GURL("https://example.com")));
+ FormFieldData field1;
+ field1.set_origin(url::Origin::Create(GURL("https://attacker.com")));
+ field1.set_is_focusable(false);
+ field1.set_form_control_type(FormControlType::kInputText);
+ FormFieldData field2;
+ field2.set_origin(url::Origin::Create(GURL("https://example.com")));
+ field2.set_is_focusable(true);
+ field2.set_form_control_type(FormControlType::kInputText);
+ form.set_fields({field1, field2});
+
+ FormStructure form_structure(form);
+ ASSERT_EQ(form_structure.fields().size(), 2u);
+ form_structure.field(0)->SetTypeTo(AutofillType(ONE_TIME_CODE), std::nullopt);
+ form_structure.field(1)->SetTypeTo(AutofillType(ONE_TIME_CODE), std::nullopt);
+
+ EXPECT_FALSE(OtpFieldDetector::IsOtpForm(form_structure));
+ EXPECT_FALSE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(0)));
+ EXPECT_TRUE(
+ OtpFieldDetector::IsOtpField(form_structure, *form_structure.field(1)));
}
} // namespace autofill
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
index a4312a8..bff18640 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
@@ -155,7 +155,12 @@
&TestBrowserAutofillManager::Observer::OnFieldTypesDetermined, form_id,
TestBrowserAutofillManager::Observer::FieldTypeSource::kAutofillAiModel,
/*small_forms_were_parsed=*/false);
- return autofill_manager().FindCachedFormById(form_id);
+ const FormStructure* cached_form =
+ autofill_manager().FindCachedFormById(form_id);
+ if (cached_form && !cached_form->fields().empty()) {
+ test_field_ = *cached_form->field(0);
+ }
+ return cached_form;
}
const FormStructure* AddFormWithOtpField(
@@ -166,11 +171,13 @@
.fields =
{
{.server_type = ONE_TIME_CODE,
+ .host_frame = autofill_driver().GetFrameToken(),
.is_focusable = is_focusable,
.label = u"OTP",
.name = u"otp",
.origin = field_origin},
},
+ .host_frame = autofill_driver().GetFrameToken(),
.main_frame_origin = main_frame_origin,
};
return AddForm(form_description);
@@ -632,12 +639,17 @@
EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp)
.WillOnce(RunOnceCallback<0>(otp));
- const FormStructure* form = AddFormWithOtpField();
- ASSERT_TRUE(form);
-
LocalFrameToken subframe_token = test::MakeLocalFrameToken();
- FormFieldData field = *form->field(0);
- field.set_host_frame(subframe_token);
+ const FormStructure* form = AddForm({
+ .fields =
+ {
+ {.server_type = ONE_TIME_CODE,
+ .host_frame = subframe_token,
+ .label = u"OTP",
+ .name = u"otp"},
+ },
+ });
+ ASSERT_TRUE(form);
EXPECT_CALL(otp_phish_guard_delegate(), StartOtpPhishGuardCheck)
.WillOnce([&](LocalFrameToken frame_to_fill,
@@ -647,7 +659,7 @@
});
base::test::TestFuture<std::vector<one_time_tokens::OneTimeToken>> future;
- otp_manager.GetOtpSuggestions(*form, field, future.GetCallback());
+ otp_manager.GetOtpSuggestions(*form, *form->field(0), future.GetCallback());
ASSERT_EQ(future.Get().size(), 1u);
EXPECT_EQ(future.Get()[0].value(), otp.value());
@@ -1294,6 +1306,130 @@
EXPECT_TRUE(future.Get().empty());
}
+// Tests that in a same-origin form containing both a password `ONE_TIME_CODE`
+// field and a text `ONE_TIME_CODE` field, `GetOtpSuggestions` returns empty
+// suggestions when triggered on the password field, and returns the OTP when
+// triggered on the text field.
+TEST_F(OtpManagerImplTest,
+ GetOtpSuggestionsPasswordFieldInMixedFormReturnsEmpty) {
+ OtpManagerImpl otp_manager(autofill_manager(), &one_time_token_service_);
+
+ one_time_tokens::OneTimeToken otp(one_time_tokens::OneTimeTokenType::kSmsOtp,
+ kDefaultOtpValue, base::TimeTicks::Now());
+ EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp)
+ .WillOnce(RunOnceCallback<0>(otp));
+
+ const FormStructure* form = AddForm({
+ .fields =
+ {
+ {.server_type = ONE_TIME_CODE,
+ .is_focusable = true,
+ .label = u"Password OTP",
+ .name = u"pw_otp",
+ .form_control_type = FormControlType::kInputPassword},
+ {.server_type = ONE_TIME_CODE,
+ .is_focusable = true,
+ .label = u"Text OTP",
+ .name = u"text_otp",
+ .form_control_type = FormControlType::kInputText},
+ },
+ });
+ ASSERT_TRUE(form);
+ ASSERT_EQ(form->fields().size(), 2u);
+
+ // Triggering on the password field must not return suggestions.
+ EXPECT_CALL(otp_phish_guard_delegate(), StartOtpPhishGuardCheck).Times(0);
+ base::test::TestFuture<std::vector<one_time_tokens::OneTimeToken>>
+ password_future;
+ otp_manager.GetOtpSuggestions(*form, *form->field(0),
+ password_future.GetCallback());
+ EXPECT_TRUE(password_future.IsReady());
+ EXPECT_TRUE(password_future.Get().empty());
+
+ // Triggering on the text OTP field returns the OTP suggestion.
+ EXPECT_CALL(otp_phish_guard_delegate(), StartOtpPhishGuardCheck)
+ .WillOnce(RunOnceCallback<1>(false));
+ base::test::TestFuture<std::vector<one_time_tokens::OneTimeToken>>
+ text_future;
+ otp_manager.GetOtpSuggestions(*form, *form->field(1),
+ text_future.GetCallback());
+ ASSERT_EQ(text_future.Get().size(), 1u);
+ EXPECT_EQ(text_future.Get()[0].value(), otp.value());
+}
+
+// Tests that a cross-origin password `ONE_TIME_CODE` field injected alongside a
+// same-origin text `ONE_TIME_CODE` field blocks OTP retrieval and suggestions.
+TEST_F(OtpManagerImplTest,
+ CrossOriginPasswordOtpFieldWithSameOriginTextOtpFieldReturnsEmpty) {
+ OtpManagerImpl otp_manager(autofill_manager(), &one_time_token_service_);
+
+ EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp).Times(0);
+ EXPECT_CALL(otp_phish_guard_delegate(), StartOtpPhishGuardCheck).Times(0);
+
+ const FormStructure* form = AddForm({
+ .fields =
+ {
+ {.server_type = ONE_TIME_CODE,
+ .is_focusable = true,
+ .label = u"Cross-origin Password OTP",
+ .name = u"attacker_otp",
+ .form_control_type = FormControlType::kInputPassword,
+ .origin = url::Origin::Create(GURL("https://attacker.test"))},
+ {.server_type = ONE_TIME_CODE,
+ .is_focusable = true,
+ .label = u"Main-frame Text OTP",
+ .name = u"main_otp",
+ .form_control_type = FormControlType::kInputText,
+ .origin = url::Origin::Create(GURL("https://example.test"))},
+ },
+ .main_frame_origin = url::Origin::Create(GURL("https://example.test")),
+ });
+ ASSERT_TRUE(form);
+ ASSERT_EQ(form->fields().size(), 2u);
+
+ base::test::TestFuture<std::vector<one_time_tokens::OneTimeToken>>
+ cross_origin_future;
+ otp_manager.GetOtpSuggestions(*form, *form->field(0),
+ cross_origin_future.GetCallback());
+ EXPECT_TRUE(cross_origin_future.IsReady());
+ EXPECT_TRUE(cross_origin_future.Get().empty());
+
+ base::test::TestFuture<std::vector<one_time_tokens::OneTimeToken>>
+ same_origin_future;
+ otp_manager.GetOtpSuggestions(*form, *form->field(1),
+ same_origin_future.GetCallback());
+ EXPECT_TRUE(same_origin_future.IsReady());
+ EXPECT_TRUE(same_origin_future.Get().empty());
+}
+
+// Tests that `GetOtpSuggestions` returns empty suggestions if the trigger field
+// itself has a cross-origin (mismatched eTLD+1) origin, even when `form` is a
+// valid same-origin OTP form.
+TEST_F(OtpManagerImplTest,
+ GetOtpSuggestionsCrossOriginTriggerFieldOnValidFormReturnsEmpty) {
+ OtpManagerImpl otp_manager(autofill_manager(), &one_time_token_service_);
+
+ EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp).Times(1);
+ const FormStructure* form = AddFormWithOtpField(
+ /*field_origin=*/url::Origin::Create(GURL("https://example.test")),
+ /*main_frame_origin=*/url::Origin::Create(GURL("https://example.test")));
+ ASSERT_TRUE(form);
+ ASSERT_EQ(form->fields().size(), 1u);
+
+ EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp).Times(0);
+ EXPECT_CALL(otp_phish_guard_delegate(), StartOtpPhishGuardCheck).Times(0);
+
+ FormFieldData cross_origin_field = *form->field(0);
+ cross_origin_field.set_origin(
+ url::Origin::Create(GURL("https://attacker.test")));
+
+ base::test::TestFuture<std::vector<one_time_tokens::OneTimeToken>> future;
+ otp_manager.GetOtpSuggestions(*form, cross_origin_field,
+ future.GetCallback());
+ EXPECT_TRUE(future.IsReady());
+ EXPECT_TRUE(future.Get().empty());
+}
+
// Tests that `GetOtpSuggestions` immediately returns the mock OTP value
// when `kMockOtpValue` is specified on the command line, short-circuiting
// backend retrieval and PhishGuard checks.
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl_unittest.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl_unittest.cc
index 9b2720e..0f34cf3 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl_unittest.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl_unittest.cc
@@ -109,7 +109,12 @@
&TestBrowserAutofillManager::Observer::OnFieldTypesDetermined, form_id,
TestBrowserAutofillManager::Observer::FieldTypeSource::kAutofillAiModel,
/*small_forms_were_parsed=*/false);
- return autofill_manager().FindCachedFormById(form_id);
+ const FormStructure* cached_form =
+ autofill_manager().FindCachedFormById(form_id);
+ if (cached_form && !cached_form->fields().empty()) {
+ test_field_ = *cached_form->field(0);
+ }
+ return cached_form;
}
const FormStructure* AddFormWithOtpField(
@@ -120,11 +125,13 @@
.fields =
{
{.server_type = ONE_TIME_CODE,
+ .host_frame = autofill_driver().GetFrameToken(),
.is_focusable = is_focusable,
... (truncated)