Low chrome Logic Error 🔧 Commit mapped

Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Autofill
DescriptionIncorrect authorization in Autofill
ComponentAutofill
Bug ClassLogic Error
Tracker498725213
Fix commit3398d1c5d778 (chromium/src) +118/-21
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-01

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
if
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
modified
OtpFieldDetectorAutofillManagerObserverTest
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
modified
OtpFieldDetectorAutofillManagerObserverTest
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
modified
TEST_F
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
modified
TEST_P
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
modified

Files Changed

  • chrome/browser/password_manager/password_change_browsertest.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.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 3398d1c5d778475e065162de22093b52bfd9a2ec Mon Sep 17 00:00:00 2001
From: Matthias Koerber <koerber@google.com>
Date: Thu, 30 Apr 2026 04:35:31 -0700
Subject: [PATCH] [OTPxIFrame] Restrict OTP field detection to TLD+1 matching iframes

This CL implements a cross-origin security check for OTP field
detection, guarded by the `kAutofillRestrictOtpToSameTldPlusOne` feature
flag.

Previously, OtpFieldDetector would notify the UI (like the keyboard
accessory) about OTP fields in iframes without verifying if the iframe
matched the TLD+1 of the top-level frame.

We now use `net::registry_controlled_domains::SameDomainOrHost` to
compare the OTP field's origin and the main frame's origin. If they do
not match (i.e., they don't share the same TLD+1), the OtpFieldDetector
will ignore the form.

Bug: 498725213
Change-Id: Ifc0a762a29087c85b13355f4633f3162d9f71281
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7776873
Auto-Submit: Matthias Körber <koerber@google.com>
Reviewed-by: Jihad Hanna <jihadghanna@google.com>
Reviewed-by: Christoph Schwering <schwering@google.com>
Commit-Queue: Matthias Körber <koerber@google.com>
Cr-Commit-Position: refs/heads/main@{#1623079}
---

diff --git a/chrome/browser/password_manager/password_change_browsertest.cc b/chrome/browser/password_manager/password_change_browsertest.cc
index 396f102..73efe78 100644
--- a/chrome/browser/password_manager/password_change_browsertest.cc
+++ b/chrome/browser/password_manager/password_change_browsertest.cc
@@ -375,7 +375,7 @@
     autofill::FormFieldData field = {autofill::test::CreateTestFormField(
         "some_label", "some_name", "some_value",
         autofill::FormControlType::kInputText)};
-
+    field.set_origin(url::Origin::Create(form.url()));
     form.set_fields({field});
     return autofill::test::CreateFormDataForFrame(form, frame_token);
   }
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 2b07551..1b9da092 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
@@ -4,27 +4,53 @@
 
 #include "components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h"
 
+#include "base/feature_list.h"
 #include "base/metrics/histogram_functions.h"
 #include "components/autofill/core/browser/form_structure.h"
 #include "components/autofill/core/browser/foundations/autofill_client.h"
+#include "components/autofill/core/common/autofill_features.h"
+#include "net/base/registry_controlled_domains/registry_controlled_domain.h"
 
 namespace autofill {
 
 namespace {
 
-// Returns if `form` in `manager` contains at least one `ONE_TIME_CODE` field.
+// 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) {
-  return std::ranges::any_of(
-      form.fields(), [](const std::unique_ptr<AutofillField>& f) {
-        return f->Type().GetTypes().contains(ONE_TIME_CODE) &&
-               f->is_focusable();
-      });
+  const bool restrict_to_same_tld = base::FeatureList::IsEnabled(
+      features::kAutofillRestrictOtpToSameTldPlusOne);
+
+  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()) {
+      continue;
+    }
+    has_otp_field = true;
+    if (!restrict_to_same_tld) {
+      return true;
+    }
+
+    if (!net::registry_controlled_domains::SameDomainOrHost(
+            f->origin(), form.main_frame_origin(),
+            net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES)) {
+      // 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;
+    }
+  }
+
+  return has_otp_field;
 }
 
-// Returns if `form` in `manager` contains at least one `ONE_TIME_CODE` 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) {
-  const FormStructure* form_structure = manager.FindCachedFormById(form);
+                             FormGlobalId form_id) {
+  const FormStructure* form_structure = manager.FindCachedFormById(form_id);
   return form_structure && IsOtpForm(*form_structure);
 }
 
@@ -92,7 +118,7 @@
     });
   } else {
     manager.ForEachCachedForm([&](const FormStructure& form) {
-      if (IsOtpForm(manager, form.global_id())) {
+      if (IsOtpForm(form)) {
         AddFormAndNotifyIfNecessary(form.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 82f513c..994f56f 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
@@ -4,18 +4,23 @@
 
 #include "components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h"
 
+#include <optional>
+
 #include "base/test/bind.h"
 #include "base/test/metrics/histogram_tester.h"
 #include "base/test/mock_callback.h"
+#include "base/test/scoped_feature_list.h"
 #include "base/test/task_environment.h"
 #include "components/autofill/core/browser/foundations/browser_autofill_manager.h"
 #include "components/autofill/core/browser/foundations/test_autofill_client.h"
 #include "components/autofill/core/browser/foundations/test_autofill_driver.h"
 #include "components/autofill/core/browser/foundations/test_browser_autofill_manager.h"
 #include "components/autofill/core/browser/foundations/with_test_autofill_client_driver_manager.h"
+#include "components/autofill/core/common/autofill_features.h"
 #include "components/autofill/core/common/autofill_test_utils.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "url/origin.h"
 
 namespace autofill {
 
@@ -139,10 +144,13 @@
 
 // Tests that the AutofillManager::Observer notifications work as expected.
 class OtpFieldDetectorAutofillManagerObserverTest
-    : public testing::Test,
+    : public testing::TestWithParam<bool>,
       public WithTestAutofillClientDriverManager<> {
  public:
-  OtpFieldDetectorAutofillManagerObserverTest() = default;
+  OtpFieldDetectorAutofillManagerObserverTest() {
+    scoped_feature_list_.InitWithFeatureState(
+        features::kAutofillRestrictOtpToSameTldPlusOne, GetParam());
+  }
   ~OtpFieldDetectorAutofillManagerObserverTest() override = default;
 
   void SetUp() override {
@@ -162,12 +170,18 @@
         AutofillDriver::LifecycleState::kPendingDeletion);
   }
 
-  FormData CreateSimpleOtp(bool is_focusable = true) {
+  FormData CreateSimpleOtp(
+      bool is_focusable = true,
+      const GURL& url = GURL("https://www.foo.com"),
+      std::optional<url::Origin> main_frame_origin = std::nullopt) {
     FormData form;
-    form.set_url(GURL("https://www.foo.com"));
+    form.set_url(url);
+    form.set_main_frame_origin(
+        main_frame_origin.value_or(url::Origin::Create(url)));
     form.set_renderer_id(autofill::test::MakeFormRendererId());
     FormFieldData field = {autofill::test::CreateTestFormField(
         "some_label", "some_name", "some_value", FormControlType::kInputText)};
+    field.set_origin(url::Origin::Create(url));
     field.set_is_focusable(is_focusable);
     form.set_fields({field});
     return form;
@@ -202,6 +216,7 @@
   OtpFieldDetector& otp_field_detector() { return otp_field_detector_; }
 
  private:
+  base::test::ScopedFeatureList scoped_feature_list_;
   base::test::TaskEnvironment task_environment_{
       base::test::TaskEnvironment::TimeSource::MOCK_TIME};
   autofill::test::AutofillUnitTestEnvironment autofill_environment_;
@@ -215,7 +230,7 @@
 };
 
 // Verify that IsOtpFieldPresent works as expected.
-TEST_F(OtpFieldDetectorAutofillManagerObserverTest, IsOtpFieldPresent) {
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest, IsOtpFieldPresent) {
   base::HistogramTester histogram_tester;
   EXPECT_FALSE(otp_field_detector().IsOtpFieldPresent());
   EXPECT_THAT(histogram_tester.GetAllSamples(kOtpPresentInMainTabHistogram),
@@ -237,7 +252,7 @@
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/password_manager/password_change_browsertest.cc b/chrome/browser/password_manager/password_change_browsertest.cc
index 396f102..73efe78 100644
--- a/chrome/browser/password_manager/password_change_browsertest.cc
+++ b/chrome/browser/password_manager/password_change_browsertest.cc
@@ -375,7 +375,7 @@
     autofill::FormFieldData field = {autofill::test::CreateTestFormField(
         "some_label", "some_name", "some_value",
         autofill::FormControlType::kInputText)};
-
+    field.set_origin(url::Origin::Create(form.url()));
     form.set_fields({field});
     return autofill::test::CreateFormDataForFrame(form, frame_token);
   }
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 82f513c..994f56f 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
@@ -4,18 +4,23 @@
 
 #include "components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.h"
 
+#include <optional>
+
 #include "base/test/bind.h"
 #include "base/test/metrics/histogram_tester.h"
 #include "base/test/mock_callback.h"
+#include "base/test/scoped_feature_list.h"
 #include "base/test/task_environment.h"
 #include "components/autofill/core/browser/foundations/browser_autofill_manager.h"
 #include "components/autofill/core/browser/foundations/test_autofill_client.h"
 #include "components/autofill/core/browser/foundations/test_autofill_driver.h"
 #include "components/autofill/core/browser/foundations/test_browser_autofill_manager.h"
 #include "components/autofill/core/browser/foundations/with_test_autofill_client_driver_manager.h"
+#include "components/autofill/core/common/autofill_features.h"
 #include "components/autofill/core/common/autofill_test_utils.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "url/origin.h"
 
 namespace autofill {
 
@@ -139,10 +144,13 @@
 
 // Tests that the AutofillManager::Observer notifications work as expected.
 class OtpFieldDetectorAutofillManagerObserverTest
-    : public testing::Test,
+    : public testing::TestWithParam<bool>,
       public WithTestAutofillClientDriverManager<> {
  public:
-  OtpFieldDetectorAutofillManagerObserverTest() = default;
+  OtpFieldDetectorAutofillManagerObserverTest() {
+    scoped_feature_list_.InitWithFeatureState(
+        features::kAutofillRestrictOtpToSameTldPlusOne, GetParam());
+  }
   ~OtpFieldDetectorAutofillManagerObserverTest() override = default;
 
   void SetUp() override {
@@ -162,12 +170,18 @@
         AutofillDriver::LifecycleState::kPendingDeletion);
   }
 
-  FormData CreateSimpleOtp(bool is_focusable = true) {
+  FormData CreateSimpleOtp(
+      bool is_focusable = true,
+      const GURL& url = GURL("https://www.foo.com"),
+      std::optional<url::Origin> main_frame_origin = std::nullopt) {
     FormData form;
-    form.set_url(GURL("https://www.foo.com"));
+    form.set_url(url);
+    form.set_main_frame_origin(
+        main_frame_origin.value_or(url::Origin::Create(url)));
     form.set_renderer_id(autofill::test::MakeFormRendererId());
     FormFieldData field = {autofill::test::CreateTestFormField(
         "some_label", "some_name", "some_value", FormControlType::kInputText)};
+    field.set_origin(url::Origin::Create(url));
     field.set_is_focusable(is_focusable);
     form.set_fields({field});
     return form;
@@ -202,6 +216,7 @@
   OtpFieldDetector& otp_field_detector() { return otp_field_detector_; }
 
  private:
+  base::test::ScopedFeatureList scoped_feature_list_;
   base::test::TaskEnvironment task_environment_{
       base::test::TaskEnvironment::TimeSource::MOCK_TIME};
   autofill::test::AutofillUnitTestEnvironment autofill_environment_;
@@ -215,7 +230,7 @@
 };
 
 // Verify that IsOtpFieldPresent works as expected.
-TEST_F(OtpFieldDetectorAutofillManagerObserverTest, IsOtpFieldPresent) {
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest, IsOtpFieldPresent) {
   base::HistogramTester histogram_tester;
   EXPECT_FALSE(otp_field_detector().IsOtpFieldPresent());
   EXPECT_THAT(histogram_tester.GetAllSamples(kOtpPresentInMainTabHistogram),
@@ -237,7 +252,7 @@
 
 // Verify that the OtpFieldsDetectedCallback is triggered when an OTP form is
 // detected.
-TEST_F(OtpFieldDetectorAutofillManagerObserverTest, DiscoverOTPs) {
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest, DiscoverOTPs) {
   base::MockRepeatingCallback<void()> otp_detected_callback;
   MockFunction<void(std::string_view)> check;
   {
@@ -259,7 +274,7 @@
 
 // Verify that an OTP form that is parsed but has non-focusable fields does
 // not trigger the OTP detected callback.
-TEST_F(OtpFieldDetectorAutofillManagerObserverTest,
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest,
        DiscoverOTPs_IgnoreNonfocusableOtpFields) {
   base::MockRepeatingCallback<void()> otp_detected_callback;
   base::CallbackListSubscription subscription =
@@ -271,7 +286,7 @@
 }
 
 // Verify that a navigation which drops all forms is recognized.
-TEST_F(OtpFieldDetectorAutofillManagerObserverTest,
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest,
        CallbackInvokedAfterNavigationClearsOtps) {
   AddOtpToThePage(CreateSimpleOtp());
 
@@ -294,7 +309,7 @@
 }
 
 // Verify that removing an OTP form from the DOM is detected.
-TEST_F(OtpFieldDetectorAutofillManagerObserverTest,
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest,
        CallbackInvokedFromFormRemoval) {
   FormData form = CreateSimpleOtp();
   AddOtpToThePage(form);
@@ -324,7 +339,7 @@
 }
 
 // Verify that submitting an OTP form is detected.
-TEST_F(OtpFieldDetectorAutofillManagerObserverTest,
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest,
        CallbackInvokedFromFormSubmission) {
   FormData form = CreateSimpleOtp();
   AddOtpToThePage(form);
@@ -355,4 +370,53 @@
   SimulateNavigation();
 }
 
+// Verify that OTP fields in the main frame trigger detection callbacks.
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest, AllowsMainFrame) {
+  base::MockRepeatingCallback<void()> otp_detected_callback;
+  base::CallbackListSubscription subscription =
+      otp_field_detector().RegisterOtpFieldsDetectedCallback(
+          otp_detected_callback.Get());
+
+  EXPECT_CALL(otp_detected_callback, Run()).Times(1);
+  AddOtpToThePage(CreateSimpleOtp());
+}
+
+// Verify that OTP fields in iframes with the same TLD+1 trigger detection
+// callbacks.
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest,
+       AllowsSameTldPlusOneIframe) {
+  url::Origin top_frame_origin =
+      url::Origin::Create(GURL("https://example.com"));
+
+  base::MockRepeatingCallback<void()> otp_detected_callback;
+  base::CallbackListSubscription subscription =
+      otp_field_detector().RegisterOtpFieldsDetectedCallback(
+          otp_detected_callback.Get());
+
+  EXPECT_CALL(otp_detected_callback, Run()).Times(1);
+  AddOtpToThePage(
+      CreateSimpleOtp(true, GURL("https://sub.example.com"), top_frame_origin));
+}
+
+// Verify that OTP fields in cross-origin iframes with mismatched TLD+1 are
+// ignored and do not trigger detection callbacks.
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest, IgnoreCrossOriginIframe) {
+  // Set up mismatched TLD+1 origins.
+  url::Origin top_frame_origin =
+      url::Origin::Create(GURL("https://example.com"));
+
+  base::MockRepeatingCallback<void()> otp_detected_callback;
+  base::CallbackListSubscription subscription =
+      otp_field_detector().RegisterOtpFieldsDetectedCallback(
+          otp_detected_callback.Get());
+
+  EXPECT_CALL(otp_detected_callback, Run()).Times(GetParam() ? 0 : 1);
+  AddOtpToThePage(
+      CreateSimpleOtp(true, GURL("https://attacker.com"), top_frame_origin));
+}
+
+INSTANTIATE_TEST_SUITE_P(All,
+                         OtpFieldDetectorAutofillManagerObserverTest,
+                         testing::Bool());
+
 }  // namespace autofill
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.