High chrome Cross Origin 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in Autofill
DescriptionMemory corruption in Autofill
ComponentAutofill
Sub-componentOTP
Bug ClassCross Origin
Tracker567673200
Fix commit4b47de55fa51 (chromium/src) +425/-40
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-29

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 as ONE_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, holding AutofillFields) vs. the raw renderer-supplied field (FormFieldData). Each field carries an origin(); the form carries main_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.

Key insight
OTP eligibility was enforced at the granularity of the whole form but the sensitive token was delivered per field; a form that qualified on one legitimate field would leak the OTP to any other ONE_TIME_CODE field in it, including a cross-origin or password field an attacker planted.

Attack Path

  1. 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-origin ONE_TIME_CODE text field, and adds a second ONE_TIME_CODE field controlled by the attacker (cross-origin subframe field, or a password-typed input) (inferred, from the test scenarios the patch adds).
  2. Trigger OTP flow Induce focus / suggestion on the attacker-controlled ONE_TIME_CODE field so GetOtpSuggestions runs; pre-patch it passed because only IsOtpForm and field.origin().opaque() were checked.
  3. OTP fetched and targeted The manager fetches the real one-time token and, via the otp_field_ids/GetTargetField loops that matched on bare contains(ONE_TIME_CODE), treats the attacker field as a valid fill target.
  4. 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

This is a browser-process (Autofill) logic flaw with no memory-safety component. It requires the victim to be on a page with an OTP form and to engage the OTP suggestion flow; no elevated privilege is needed. The gained primitive is disclosure of a one-time authentication code to an ineligible (cross-origin or password) field — a confidentiality breach that can defeat SMS-OTP second factors. It is not RCE and not memory corruption; the advisory’s “Memory corruption” string is inaccurate for this commit. Strength is bounded by the attacker’s ability to construct a qualifying form and have the user trigger the flow.

Changed Functions

FunctionChangeNotes
AutofillClient
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h
modified
AutofillField
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h
modified
FormFieldData
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h
modified
FormStructure
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h
modified
if
components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
modified
TEST
components/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.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl_unittest.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_manager_legacy_impl.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h
  • components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc

Audit Directions

  • Form-level vs. field-level gates
    Audit other Autofill integrators (passwords, payment, addresses) for places that decide eligibility over a whole FormStructure but then act on individual fields matched only by type, without re-checking origin/focusability.
  • Origin checks on sensitive fill
    Review every path that returns a secret (OTP, stored credential) to the renderer for a per-field SameDomainOrHost/CanAccess check against the main frame origin, especially where opaque-origin is the only guard.
  • Cross-frame field aggregation
    Inspect 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.
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.