Overview

Low
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Autofill
DescriptionIncorrect authorization in Autofill
ComponentAutofill
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker498725213
Fix commit096c31af7536 (chromium/src) +422/-82
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-01 (per vendor advisory)
Fix landed2026-08-17 (public git)
Disclosed2026-09-01
Patch gap15 days — fix public in source before the advisory

Background

Autofill OTP detection
Chromium’s Autofill subsystem that scans parsed forms for one-time-code inputs so surfaces like the keyboard accessory can offer to fill them.
`ONE_TIME_CODE`
the Autofill field type assigned to inputs recognized as one-time passcode entry fields.
TLD+1
the registrable domain (top-level domain plus one label, e.g. example.com), the granularity Chromium uses to decide whether two origins are “same-site”.
`net::registry_controlled_domains::SameDomainOrHost`
a net utility that returns whether two origins share the same registry-controlled domain (TLD+1).

Root Cause Analysis

The private helper IsOtpForm in otp_field_detector.cc classified a form as an OTP form whenever any focusable field carried the ONE_TIME_CODE type, using std::ranges::any_of with no check on the origin of that field. Because a form’s fields can live inside cross-origin iframes, an attacker-controlled iframe embedding an OTP field could cause the OtpFieldDetector to notify UI surfaces (such as the keyboard accessory) about an OTP field that did not belong to the top-level site. The violated invariant is that an OTP prompt on the main tab should only be driven by fields same-site with the top-level frame, so that a cross-origin embedded frame cannot solicit the user’s one-time code.

The fix iterates the fields explicitly and, when kAutofillRestrictOtpToSameTldPlusOne is enabled, compares each ONE_TIME_CODE field’s origin() against form.main_frame_origin() via SameDomainOrHost, dropping the entire form if any OTP field is cross-site. This works because it re-establishes the origin binding between the detected OTP field and the frame the user is actually interacting with.

Key insight
The core mistake was treating field type alone as sufficient authorization to surface an OTP prompt, ignoring which origin/frame the field belonged to; the fix adds an explicit same-TLD+1 origin comparison between each OTP field and the main frame before the form is considered an OTP form.

Attack Path

  1. Embed a hostile iframe A malicious site frames content (or a legitimate site embeds a cross-origin frame) that renders an input parsed by Autofill as a ONE_TIME_CODE field.
  2. Trigger detection When Autofill processes cached forms, IsOtpForm sees the focusable ONE_TIME_CODE field and classifies the form as an OTP form regardless of its origin.
  3. UI notification fires OtpFieldDetector calls AddFormAndNotifyIfNecessary, so surfaces like the keyboard accessory present an OTP-related affordance for the cross-origin field.
  4. User misdirection The user, trusting the top-level site, may direct or expose their one-time code to a field that actually belongs to a different-origin embedded frame.

Impact Assessment

An attacker gains an incorrect-authorization condition in the browser’s Autofill UI: a cross-origin iframe can cause OTP-fill affordances to appear as if authorized for the top-level page, in the browser process’s Autofill/UI context. Preconditions are that the victim visits a page embedding an attacker-controlled cross-origin frame containing an OTP-typed field; the severity is rated low and the hardened behavior is gated behind the kAutofillRestrictOtpToSameTldPlusOne feature flag.

Changed Functions

FunctionChangeNotes
OtpFieldDetector
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
modified
if
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
modified
AutofillClient
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
FormStructure
components/autofill/core/browser/integrators/one_time_tokens/otp_manager.h
modified
if
components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
modified
BrowserAutofillManager
components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.h
modified
FormStructure
components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.h
modified
LogBuffer
components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.h
modified
TEST
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
modified

Files Changed

  • components/autofill/core/browser/suggestions/one_time_passwords/otp_suggestion_generator_unittest.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.h
  • components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
  • components/autofill/core/browser/integrators/one_time_tokens/mock_otp_manager.h
  • components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h
  • components/autofill/core/browser/integrators/one_time_tokens/otp_manager.h
  • components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
  • components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc
  • components/autofill/core/browser/suggestions/one_time_passwords/otp_suggestion_generator.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_unittest.cc
  • components/autofill/core/browser/foundations/browser_autofill_manager.cc

Audit Directions

  • Field-type-only trust
    Flag any Autofill or form-handling logic that keys UI or fill decisions on a field’s type or attributes without checking the field’s origin() against the relevant frame origin.
  • Cross-frame form aggregation
    Review code that iterates form.fields() treating a form as a single-origin unit, since Autofill forms may span cross-origin iframes and need per-field origin checks.
  • Same-site comparisons
    Audit uses of SameDomainOrHost and origin comparisons to confirm the correct pair of origins is compared and that the INCLUDE_PRIVATE_REGISTRIES policy matches the security intent.
From 096c31af75365c2a3ef7c1854d71045d2ce5ec89 Mon Sep 17 00:00:00 2001
From: Matthias Koerber <koerber@google.com>
Date: Mon, 17 Aug 2026 08:22:43 -0700
Subject: [PATCH] Apply TLD+1 matching to SMS OTP

This change ensures that kAutofillRestrictOtpToSameTldPlusOne is respected in OtpManagerImpl before fetching SMS OTPs.

Specifically:
1. OtpFieldDetector::IsOtpForm is made public static so it can be shared.
2. OtpManagerImpl now calls OtpFieldDetector::IsOtpForm in OnFieldTypesDetermined and GetOtpSuggestions before subscribing/fetching.
3. OtpManager::GetOtpSuggestions interface is updated to accept FormStructure so that implementations can check if the form is valid.

TAG=agy
CONV=76fbbabd-2d8a-44db-ad4b-ddfaeda7296e
Fixed: 498725213

Change-Id: I95cc51debaec1f187c51f99911e2a3dc99f46209
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8232118
Reviewed-by: Jihad Hanna <jihadghanna@google.com>
Commit-Queue: Matthias Körber <koerber@google.com>
Cr-Commit-Position: refs/heads/main@{#1680560}
---
diff --git a/components/autofill/core/browser/foundations/browser_autofill_manager.cc b/components/autofill/core/browser/foundations/browser_autofill_manager.cc
index 6c3524c..5aa0c9f 100644
--- a/components/autofill/core/browser/foundations/browser_autofill_manager.cc
+++ b/components/autofill/core/browser/foundations/browser_autofill_manager.cc
@@ -1420,9 +1420,10 @@
   // still need to offer Autocomplete.
   // TODO(crbug.com/433224307): Consider early returning here when the cache
   // starts storing all forms and fields.
-  AutofillField* autofill_field =
-      FindMutableFormAndField(form.global_id(), field.global_id())
-          .autofill_field;
+  MutableFormAndField form_and_field =
+      FindMutableFormAndField(form.global_id(), field.global_id());
+  FormStructure* form_structure = form_and_field.form_structure;
+  AutofillField* autofill_field = form_and_field.autofill_field;
 
   auto generate_suggestions_and_maybe_show_ui_phase2 = base::BindOnce(
       &BrowserAutofillManager::GenerateSuggestionsAndMaybeShowUIPhase2,
@@ -1432,14 +1433,14 @@
   // `otp_manager_` may not be instantiated on all platforms. If a focused field
   // is not classified, `autofill_field` is null but the field may be filled by
   // autocomplete.
-  if (otp_manager_ && autofill_field &&
+  if (otp_manager_ && form_structure && autofill_field &&
       autofill_field->Type().GetTypes().contains(ONE_TIME_CODE)) {
     if (!client().IsContextSecure()) {
       std::move(generate_suggestions_and_maybe_show_ui_phase2).Run({});
       return;
     }
     otp_manager_->GetOtpSuggestions(
-        field.origin(),
+        *form_structure, field.origin(),
         std::move(generate_suggestions_and_maybe_show_ui_phase2));
     return;
   }
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/mock_otp_manager.h b/components/autofill/core/browser/integrators/one_time_tokens/mock_otp_manager.h
index db1e8b3..b89c03f 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/mock_otp_manager.h
+++ b/components/autofill/core/browser/integrators/one_time_tokens/mock_otp_manager.h
@@ -21,7 +21,8 @@
 
   MOCK_METHOD(void,
               GetOtpSuggestions,
-              (const url::Origin& origin,
+              (const FormStructure& form,
+               const url::Origin& origin,
                OtpManager::GetOtpSuggestionsCallback callback),
               (override));
 };
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 9e382bb0..42cca74 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
@@ -22,12 +22,7 @@
 
 namespace autofill {
 
-namespace {
-
-// Returns true if the form contains at least one ONE_TIME_CODE field and
-// all ONE_TIME_CODE fields in the form are same-site with the main frame's
-// origin.
-[[nodiscard]] bool IsOtpForm(const FormStructure& form) {
+bool OtpFieldDetector::IsOtpForm(const FormStructure& form) {
   const bool restrict_to_same_tld = base::FeatureList::IsEnabled(
       features::kAutofillRestrictOtpToSameTldPlusOne);
 
@@ -54,17 +49,6 @@
   return has_otp_field;
 }
 
-// Returns true if the `form_id` in `manager` contains at least one ONE_TIME_CODE
-// field and all ONE_TIME_CODE fields in the form are same-site with the main
-// frame's origin.
-[[nodiscard]] bool IsOtpForm(const AutofillManager& manager,
-                             FormGlobalId form_id) {
-  const FormStructure* form_structure = manager.FindCachedFormById(form_id);
-  return form_structure && IsOtpForm(*form_structure);
-}
-
-}  // namespace
-
 OtpFieldDetector::OtpFieldDetector(AutofillClient* client) {
   if (client) {
     autofill_manager_observation_.Observe(client);
@@ -101,7 +85,8 @@
                                               FormGlobalId form,
                                               FieldTypeSource source,
                                               bool small_forms_were_parsed) {
-  if (IsOtpForm(manager, form)) {
+  const FormStructure* form_structure = manager.FindCachedFormById(form);
+  if (form_structure && IsOtpForm(*form_structure)) {
     AddFormAndNotifyIfNecessary(form);
   } else {
     RemoveFormAndNotifyIfNecessary(form);
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 dabc860..5d1f9da 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,7 @@
 namespace autofill {
 
 class AutofillClient;
+class FormStructure;
 
 // Utility class to observe if a WebContents (with all of its frames)
 // - moves from having 0 OTP fields to >0 OTP fields (only focusable fields are
@@ -56,6 +57,12 @@
   // one OTP field.
   bool IsOtpFieldPresent() const;
 
+  // Returns true if the `form` contains at least one focusable `ONE_TIME_CODE`
+  // field. If `kAutofillRestrictOtpToSameTldPlusOne` is enabled, also requires
+  // that all focusable `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:
 
   // `OnFieldTypesDetermined` informs us incrementally about the discovery of
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager.h b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager.h
index 81bae8e..837e5cf 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager.h
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager.h
@@ -13,6 +13,8 @@
 
 namespace autofill {
 
+class FormStructure;
+
 // The OtpManager helps the BrowserAutofillManager filling OTPs into webforms.
 //
 // One instance per frame, owned by the BrowserAutofillManager.
@@ -35,7 +37,8 @@
   // an OTP arrived. That's bad for cooperative behavior. Instead the callback
   // should return immediately when no OTPs are cached but the Autofill UI
   // should be updated once OTPs arrive.
-  virtual void GetOtpSuggestions(const url::Origin& origin,
+  virtual void GetOtpSuggestions(const FormStructure& form,
+                                 const url::Origin& origin,
                                  GetOtpSuggestionsCallback callback) = 0;
 };
 
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 233cdd7..3edf6e2 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
@@ -24,6 +24,7 @@
 #include "components/autofill/core/browser/form_structure.h"
 #include "components/autofill/core/browser/foundations/autofill_client.h"
 #include "components/autofill/core/browser/foundations/browser_autofill_manager.h"
+#include "components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h"
 #include "components/autofill/core/browser/integrators/one_time_tokens/otp_phish_guard_delegate.h"
 #include "components/autofill/core/browser/logging/log_manager.h"
 #include "components/autofill/core/common/autofill_internals/log_message.h"
@@ -67,8 +68,14 @@
 OtpManagerImpl::~OtpManagerImpl() = default;
 
 void OtpManagerImpl::GetOtpSuggestions(
+    const FormStructure& form,
     const url::Origin& origin,
     OtpManagerImpl::GetOtpSuggestionsCallback callback) {
+  if (!OtpFieldDetector::IsOtpForm(form)) {
+    std::move(callback).Run({});
+    return;
+  }
+
   // TODO(crbug.com/415273270) This is just a hack to prepopulate the OTPs in
   // case no real backend is triggered. The feature definition should migrate to
   // autofill.
@@ -124,11 +131,7 @@
     return;
   }
 
-  const bool form_contains_otp_field = std::ranges::any_of(
-      form->fields(), [](const std::unique_ptr<AutofillField>& field) {
-        return field->Type().GetTypes().contains(ONE_TIME_CODE);
-      });
-  if (!form_contains_otp_field) {
+  if (!OtpFieldDetector::IsOtpForm(*form)) {
     return;
   }
 
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.h b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.h
index 9bba6f4..65a092f 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.h
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.h
@@ -28,6 +28,7 @@
 namespace autofill {
 
 class BrowserAutofillManager;
+class FormStructure;
 class LogBuffer;
 
 // Used for histograms. Do not reorder.
@@ -60,7 +61,8 @@
   // OtpManager:
   // Returns any cached OTPs (if they exist) and renews a subscription so that
   // incoming OTPs can be reported.
-  void GetOtpSuggestions(const url::Origin& origin,
+  void GetOtpSuggestions(const FormStructure& form,
+                         const url::Origin& origin,
                          GetOtpSuggestionsCallback callback) override;
 
   // AutofillManager::Observer:
diff --git a/components/autofill/core/browser/suggestions/one_time_passwords/otp_suggestion_generator.cc b/components/autofill/core/browser/suggestions/one_time_passwords/otp_suggestion_generator.cc
index 8126d934..0f9c0f4 100644
--- a/components/autofill/core/browser/suggestions/one_time_passwords/otp_suggestion_generator.cc
+++ b/components/autofill/core/browser/suggestions/one_time_passwords/otp_suggestion_generator.cc
@@ -86,7 +86,7 @@
   }
 
   otp_manager_->GetOtpSuggestions(
-      trigger_field.origin(),
+      *form_structure, trigger_field.origin(),
       base::BindOnce(&OtpSuggestionGenerator::OnOtpReturned,
                      weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
 }
diff --git a/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc b/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc
index 35885d10..e2f9f85 100644
--- a/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc
+++ b/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc
@@ -7157,7 +7157,7 @@
   // suggests that.
   const std::vector<std::string> otp_values = {"123456"};
   EXPECT_CALL(otp_manager(), GetOtpSuggestions)
-      .WillOnce(RunOnceCallback<1>(otp_values));
+      .WillOnce(RunOnceCallback<2>(otp_values));
   OnAskForValuesToFill(form, form.fields()[0]);
   EXPECT_TRUE(external_delegate()->on_suggestions_returned_seen());
 
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 df8b675b..6032d06 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
@@ -11,6 +11,8 @@
 #include "base/test/mock_callback.h"
 #include "base/test/scoped_feature_list.h"
 #include "base/test/task_environment.h"
+#include "components/autofill/core/browser/autofill_type.h"
+#include "components/autofill/core/browser/form_structure.h"
 #include "components/autofill/core/browser/foundations/autofill_manager_test_api.h"
 #include "components/autofill/core/browser/foundations/browser_autofill_manager.h"
 #include "components/autofill/core/browser/foundations/test_autofill_client.h"
@@ -421,4 +423,135 @@
                          OtpFieldDetectorAutofillManagerObserverTest,
                          testing::Bool());
 
+namespace {
+
+// Helper to construct a FormStructure with a single field for testing
+// `IsOtpForm`.
+std::unique_ptr<FormStructure> CreateFormWithField(
+    const url::Origin& main_frame_origin,
+    const url::Origin& field_origin,
+    FieldType type,
+    bool is_focusable = true) {
+  FormData form;
+  form.set_main_frame_origin(main_frame_origin);
+  FormFieldData field;
+  field.set_origin(field_origin);
+  field.set_is_focusable(is_focusable);
+  form.set_fields({field});
+
+  auto form_structure = std::make_unique<FormStructure>(form);
+  if (form_structure->fields().empty()) {
+    return nullptr;
+  }
+  form_structure->field(0)->SetTypeTo(AutofillType(type), std::nullopt);
+  return form_structure;
+}
+
+}  // namespace
+
+// Tests that `IsOtpForm` returns false when there are no OTP fields in the
+// form.
+TEST(OtpFieldDetectorIsOtpFormTest, NoOtpField) {
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*type=*/UNKNOWN_TYPE);
+  ASSERT_TRUE(form_structure);
+  EXPECT_FALSE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns false when the only OTP field is not
+// focusable.
+TEST(OtpFieldDetectorIsOtpFormTest, UnfocusableOtpField) {
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*type=*/ONE_TIME_CODE, /*is_focusable=*/false);
+  ASSERT_TRUE(form_structure);
+  EXPECT_FALSE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns true when there is a focusable OTP field on
+// the same origin as the main frame.
+TEST(OtpFieldDetectorIsOtpFormTest, FocusableOtpFieldSameOrigin) {
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*type=*/ONE_TIME_CODE, /*is_focusable=*/true);
+  ASSERT_TRUE(form_structure);
+  EXPECT_TRUE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns true when an OTP field is in a subdomain
+// iframe matching the main frame's TLD+1.
+TEST(OtpFieldDetectorIsOtpFormTest, SameTldPlusOneSubdomain) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      features::kAutofillRestrictOtpToSameTldPlusOne);
+
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://sub.example.com")),
+      /*type=*/ONE_TIME_CODE);
+  ASSERT_TRUE(form_structure);
+  EXPECT_TRUE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns false when
+// `kAutofillRestrictOtpToSameTldPlusOne` is enabled and an OTP field is in a
+// cross-origin iframe with mismatched TLD+1.
+TEST(OtpFieldDetectorIsOtpFormTest, MismatchedTldPlusOneFeatureEnabled) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      features::kAutofillRestrictOtpToSameTldPlusOne);
+
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://attacker.com")),
+      /*type=*/ONE_TIME_CODE);
+  ASSERT_TRUE(form_structure);
+  EXPECT_FALSE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns true when
+// `kAutofillRestrictOtpToSameTldPlusOne` is disabled even if an OTP field is in
+// a cross-origin iframe.
+TEST(OtpFieldDetectorIsOtpFormTest, MismatchedTldPlusOneFeatureDisabled) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndDisableFeature(
+      features::kAutofillRestrictOtpToSameTldPlusOne);
+
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://attacker.com")),
+      /*type=*/ONE_TIME_CODE);
+  ASSERT_TRUE(form_structure);
+  EXPECT_TRUE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns false when multiple OTP fields are present and
+// at least one has a mismatched TLD+1 with the main frame.
+TEST(OtpFieldDetectorIsOtpFormTest, MultipleOtpFieldsOneMismatched) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      features::kAutofillRestrictOtpToSameTldPlusOne);
+
+  FormData form;
+  form.set_main_frame_origin(url::Origin::Create(GURL("https://example.com")));
+  FormFieldData field1;
+  field1.set_origin(url::Origin::Create(GURL("https://example.com")));
+  field1.set_is_focusable(true);
+  FormFieldData field2;
+  field2.set_origin(url::Origin::Create(GURL("https://attacker.com")));
+  field2.set_is_focusable(true);
+  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));
+}
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc b/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc
index 35885d10..e2f9f85 100644
--- a/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc
+++ b/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc
@@ -7157,7 +7157,7 @@
   // suggests that.
   const std::vector<std::string> otp_values = {"123456"};
   EXPECT_CALL(otp_manager(), GetOtpSuggestions)
-      .WillOnce(RunOnceCallback<1>(otp_values));
+      .WillOnce(RunOnceCallback<2>(otp_values));
   OnAskForValuesToFill(form, form.fields()[0]);
   EXPECT_TRUE(external_delegate()->on_suggestions_returned_seen());
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 df8b675b..6032d06 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
@@ -11,6 +11,8 @@
 #include "base/test/mock_callback.h"
 #include "base/test/scoped_feature_list.h"
 #include "base/test/task_environment.h"
+#include "components/autofill/core/browser/autofill_type.h"
+#include "components/autofill/core/browser/form_structure.h"
 #include "components/autofill/core/browser/foundations/autofill_manager_test_api.h"
 #include "components/autofill/core/browser/foundations/browser_autofill_manager.h"
 #include "components/autofill/core/browser/foundations/test_autofill_client.h"
@@ -421,4 +423,135 @@
                          OtpFieldDetectorAutofillManagerObserverTest,
                          testing::Bool());
 
+namespace {
+
+// Helper to construct a FormStructure with a single field for testing
+// `IsOtpForm`.
+std::unique_ptr<FormStructure> CreateFormWithField(
+    const url::Origin& main_frame_origin,
+    const url::Origin& field_origin,
+    FieldType type,
+    bool is_focusable = true) {
+  FormData form;
+  form.set_main_frame_origin(main_frame_origin);
+  FormFieldData field;
+  field.set_origin(field_origin);
+  field.set_is_focusable(is_focusable);
+  form.set_fields({field});
+
+  auto form_structure = std::make_unique<FormStructure>(form);
+  if (form_structure->fields().empty()) {
+    return nullptr;
+  }
+  form_structure->field(0)->SetTypeTo(AutofillType(type), std::nullopt);
+  return form_structure;
+}
+
+}  // namespace
+
+// Tests that `IsOtpForm` returns false when there are no OTP fields in the
+// form.
+TEST(OtpFieldDetectorIsOtpFormTest, NoOtpField) {
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*type=*/UNKNOWN_TYPE);
+  ASSERT_TRUE(form_structure);
+  EXPECT_FALSE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns false when the only OTP field is not
+// focusable.
+TEST(OtpFieldDetectorIsOtpFormTest, UnfocusableOtpField) {
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*type=*/ONE_TIME_CODE, /*is_focusable=*/false);
+  ASSERT_TRUE(form_structure);
+  EXPECT_FALSE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns true when there is a focusable OTP field on
+// the same origin as the main frame.
+TEST(OtpFieldDetectorIsOtpFormTest, FocusableOtpFieldSameOrigin) {
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*type=*/ONE_TIME_CODE, /*is_focusable=*/true);
+  ASSERT_TRUE(form_structure);
+  EXPECT_TRUE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns true when an OTP field is in a subdomain
+// iframe matching the main frame's TLD+1.
+TEST(OtpFieldDetectorIsOtpFormTest, SameTldPlusOneSubdomain) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      features::kAutofillRestrictOtpToSameTldPlusOne);
+
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://sub.example.com")),
+      /*type=*/ONE_TIME_CODE);
+  ASSERT_TRUE(form_structure);
+  EXPECT_TRUE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns false when
+// `kAutofillRestrictOtpToSameTldPlusOne` is enabled and an OTP field is in a
+// cross-origin iframe with mismatched TLD+1.
+TEST(OtpFieldDetectorIsOtpFormTest, MismatchedTldPlusOneFeatureEnabled) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      features::kAutofillRestrictOtpToSameTldPlusOne);
+
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://attacker.com")),
+      /*type=*/ONE_TIME_CODE);
+  ASSERT_TRUE(form_structure);
+  EXPECT_FALSE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns true when
+// `kAutofillRestrictOtpToSameTldPlusOne` is disabled even if an OTP field is in
+// a cross-origin iframe.
+TEST(OtpFieldDetectorIsOtpFormTest, MismatchedTldPlusOneFeatureDisabled) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndDisableFeature(
+      features::kAutofillRestrictOtpToSameTldPlusOne);
+
+  std::unique_ptr<FormStructure> form_structure = CreateFormWithField(
+      /*main_frame_origin=*/url::Origin::Create(GURL("https://example.com")),
+      /*field_origin=*/url::Origin::Create(GURL("https://attacker.com")),
+      /*type=*/ONE_TIME_CODE);
+  ASSERT_TRUE(form_structure);
+  EXPECT_TRUE(OtpFieldDetector::IsOtpForm(*form_structure));
+}
+
+// Tests that `IsOtpForm` returns false when multiple OTP fields are present and
+// at least one has a mismatched TLD+1 with the main frame.
+TEST(OtpFieldDetectorIsOtpFormTest, MultipleOtpFieldsOneMismatched) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      features::kAutofillRestrictOtpToSameTldPlusOne);
+
+  FormData form;
+  form.set_main_frame_origin(url::Origin::Create(GURL("https://example.com")));
+  FormFieldData field1;
+  field1.set_origin(url::Origin::Create(GURL("https://example.com")));
+  field1.set_is_focusable(true);
+  FormFieldData field2;
+  field2.set_origin(url::Origin::Create(GURL("https://attacker.com")));
+  field2.set_is_focusable(true);
+  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));
+}
+
 }  // 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 b9279d7..ffe9d18 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
@@ -6,6 +6,7 @@
 
 #include "base/test/gmock_callback_support.h"
 #include "base/test/metrics/histogram_tester.h"
+#include "base/test/scoped_feature_list.h"
 #include "base/test/task_environment.h"
 #include "base/test/test_future.h"
 #include "base/time/clock.h"
@@ -17,6 +18,7 @@
 #include "components/autofill/core/browser/foundations/with_test_autofill_client_driver_manager.h"
 #include "components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_test_api.h"
 #include "components/autofill/core/browser/test_utils/autofill_form_test_utils.h"
+#include "components/autofill/core/common/autofill_features.h"
 #include "components/autofill/core/common/autofill_test_utils.h"
 #include "components/autofill/core/common/form_data.h"
 #include "components/one_time_tokens/core/browser/one_time_token.h"
@@ -26,11 +28,12 @@
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
-using autofill::test::FormDescription;
-using autofill::test::GetServerTypes;
-using base::test::RunOnceCallback;
-using one_time_tokens::OneTimeTokenServiceImpl;
-using ::testing::_;
+using ::autofill::test::FormDescription;
+using ::autofill::test::GetServerTypes;
+using ::base::test::RunOnceCallback;
+using ::one_time_tokens::OneTimeTokenServiceImpl;
+using ::testing::Test;
+
 namespace autofill {
 
 namespace {
@@ -67,7 +70,7 @@
 
 }  // namespace
 
-class OtpManagerImplTest : public testing::Test,
+class OtpManagerImplTest : public Test,
                            public WithTestAutofillClientDriverManager<> {
  public:
   OtpManagerImplTest() : one_time_token_service_(&sms_otp_backend_, nullptr) {}
@@ -85,8 +88,9 @@
     test_field_.set_origin(url::Origin::Create(GURL("https://example.test")));
   }
 
-  void AddForm(const FormDescription& form_description) {
+  const FormStructure* AddForm(const FormDescription& form_description) {
     FormData form = test::GetFormData(form_description);
+    FormGlobalId form_id = form.global_id();
     auto form_structure = std::make_unique<FormStructure>(form);
     test_api(*form_structure).SetFieldTypes(GetServerTypes(form_description));
     test_api(*form_structure).AssignSections();
@@ -97,26 +101,36 @@
     // This would typically happen during parsing but is skipped if a form is
     // injected via the test API.
     autofill_manager().NotifyObservers(
-        &TestBrowserAutofillManager::Observer::OnFieldTypesDetermined,
-        form.global_id(),
+        &TestBrowserAutofillManager::Observer::OnFieldTypesDetermined, form_id,
         TestBrowserAutofillManager::Observer::FieldTypeSource::kAutofillAiModel,
         /*small_forms_were_parsed=*/false);
+    return autofill_manager().FindCachedFormById(form_id);
   }
 
-  void AddFormWithOtpField() {
+  const FormStructure* AddFormWithOtpField(
+      std::optional<url::Origin> field_origin = std::nullopt,
+      std::optional<url::Origin> main_frame_origin = std::nullopt,
+      bool is_focusable = true) {
     FormDescription form_description = {
-        .fields = {
-            {.server_type = ONE_TIME_CODE, .label = u"OTP", .name = u"otp"},
-        }};
-    AddForm(form_description);
+        .fields =
+            {
+                {.server_type = ONE_TIME_CODE,
+                 .is_focusable = is_focusable,
+                 .label = u"OTP",
+                 .name = u"otp",
+                 .origin = field_origin},
+            },
+        .main_frame_origin = main_frame_origin,
+    };
+    return AddForm(form_description);
   }
 
-  void AddFormWithFirstNameField() {
+  const FormStructure* AddFormWithFirstNameField() {
     FormDescription form_description = {
         .fields = {
             {.server_type = NAME_FIRST, .label = u"First name", .name = u"fn"},
         }};
-    AddForm(form_description);
+    return AddForm(form_description);
   }
 
   MockOtpPhishGuardDelegate& otp_phish_guard_delegate() {
@@ -169,10 +183,12 @@
       .WillOnce(RunOnceCallback<2>(false));
 
   // Observing an OTP field is supposed to trigger an SMS OTP request.
-  AddFormWithOtpField();
+  const FormStructure* form = AddFormWithOtpField();
+  ASSERT_TRUE(form);
 
   base::test::TestFuture<const std::vector<std::string>> future;
-  otp_manager.GetOtpSuggestions(test_field_.origin(), future.GetCallback());
+  otp_manager.GetOtpSuggestions(*form, test_field_.origin(),
+                                future.GetCallback());
 
   ASSERT_EQ(future.Get().size(), 1u);
   EXPECT_EQ(future.Get()[0], otp.value());
@@ -200,10 +216,12 @@
       .WillOnce(RunOnceCallback<2>(false));
 
   // Observing an OTP field is supposed to trigger an SMS OTP request.
-  AddFormWithOtpField();
+  const FormStructure* form = AddFormWithOtpField();
+  ASSERT_TRUE(form);
 
   base::test::TestFuture<const std::vector<std::string>> future;
-  otp_manager.GetOtpSuggestions(test_field_.origin(), future.GetCallback());
+  otp_manager.GetOtpSuggestions(*form, test_field_.origin(),
+                                future.GetCallback());
 
   // The future should not be ready yet, as the SMS backend has not responded.
   EXPECT_FALSE(future.IsReady());
@@ -231,21 +249,25 @@
       .WillOnce(RunOnceCallback<2>(false));
 
   // Observing an OTP field is supposed to trigger an SMS OTP request.
-  AddFormWithOtpField();
... (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.