CVE-2026-84327
Overview
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.
Attack Path
- 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_CODEfield. - Trigger detection
When Autofill processes cached forms,
IsOtpFormsees the focusableONE_TIME_CODEfield and classifies the form as an OTP form regardless of its origin. - UI notification fires
OtpFieldDetectorcallsAddFormAndNotifyIfNecessary, so surfaces like the keyboard accessory present an OTP-related affordance for the cross-origin field. - 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
kAutofillRestrictOtpToSameTldPlusOne feature flag.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc |
modified | |
OtpFieldDetectorAutofillManagerObserverTestcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc |
modified | |
OtpFieldDetectorAutofillManagerObserverTestcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc |
modified | |
TEST_Fcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc |
modified | |
TEST_Pcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc |
modified |
Files Changed
chrome/browser/password_manager/password_change_browsertest.cccomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cccomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
Audit Directions
- Field-type-only trustFlag 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 aggregationReview 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 comparisonsAudit uses of
SameDomainOrHostand origin comparisons to confirm the correct pair of origins is compared and that theINCLUDE_PRIVATE_REGISTRIESpolicy matches the security intent.
Patch
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 @@
Regression Test / PoC
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