CVE-2026-106381
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/password_manager/ios/ios_password_manager_driver.mm |
modified | |
GURLcomponents/password_manager/ios/password_manager_driver_bridge.h |
modified | |
MockPasswordManagerClientcomponents/password_manager/ios/ios_password_manager_driver_unittest.mm |
modified | |
IOSPasswordManagerDriverTestcomponents/password_manager/ios/ios_password_manager_driver_unittest.mm |
modified | |
TEST_Fcomponents/password_manager/ios/ios_password_manager_driver_unittest.mm |
modified | |
forcomponents/password_manager/ios/ios_password_manager_driver_unittest.mm |
modified |
Files Changed
components/password_manager/ios/password_manager_driver_bridge.hcomponents/password_manager/ios/shared_password_controller.mmcomponents/password_manager/ios/ios_password_manager_driver.hcomponents/password_manager/ios/ios_password_manager_driver.mmcomponents/password_manager/ios/ios_password_manager_driver_unittest.mm
Patch
From 8f352853bbf0f56b65b88bf8b99887179a5071f6 Mon Sep 17 00:00:00 2001
From: Jean-Francois Le <jfle@google.com>
Date: Thu, 10 Sep 2026 14:39:42 -0700
Subject: [PATCH] [iOS][Passwords] Fix the frame driver committed URL.
Align Chrome iOS with Desktop behavior by returning the WebFrame URL
instead of the WebState. We can clean up the bridge API since each frame
has its own driver since crrev.com/c/3820103.
Bug: 501763586
Change-Id: I4878de0b899494ff57589a20a03c93332d4b94fb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8389718
Commit-Queue: Jean-François Le <jfle@google.com>
Auto-Submit: Jean-François Le <jfle@google.com>
Reviewed-by: Vincent Boisselle <vincb@google.com>
Cr-Commit-Position: refs/heads/main@{#1695680}
---
diff --git a/components/password_manager/ios/ios_password_manager_driver.h b/components/password_manager/ios/ios_password_manager_driver.h
index 73a9470..8f0480a 100644
--- a/components/password_manager/ios/ios_password_manager_driver.h
+++ b/components/password_manager/ios/ios_password_manager_driver.h
@@ -146,10 +146,15 @@
// FieldDataManager, to ensure it also outlives the frame.
const scoped_refptr<autofill::FieldDataManager> field_data_manager_;
- bool is_in_main_frame_;
- // The security origin associated with |web_frame_|.
+ // Indicates whether the associated web frame is the main frame.
+ bool is_in_main_frame_ = false;
+
+ // The security origin of the associated web frame.
url::Origin security_origin_;
+ // The URL of the associated web frame.
+ GURL url_;
+
// True when the conditions are met to display the proactive password
// generation bottom sheet, the sheet that is automatically triggered when you
// tap on a new password field.
diff --git a/components/password_manager/ios/ios_password_manager_driver.mm b/components/password_manager/ios/ios_password_manager_driver.mm
index 519149d6..c28cfe31 100644
--- a/components/password_manager/ios/ios_password_manager_driver.mm
+++ b/components/password_manager/ios/ios_password_manager_driver.mm
@@ -45,6 +45,10 @@
// Cache these values early, so that it can be accessed after frame deletion.
is_in_main_frame_ = web_frame->IsMainFrame();
security_origin_ = web_frame->GetSecurityOrigin();
+ url_ = web_frame->GetUrl();
+ if (url_.is_empty() && !security_origin_.opaque()) {
+ url_ = security_origin_.GetURL();
+ }
}
IOSPasswordManagerDriver::~IOSPasswordManagerDriver() = default;
@@ -211,7 +215,13 @@
}
const GURL& IOSPasswordManagerDriver::GetLastCommittedURL() const {
- return bridge_.lastCommittedURL;
+ if (is_in_main_frame_ && web_state_) {
+ const GURL& url = web_state_->GetLastCommittedURL();
+ if (!url.is_empty()) {
+ return url;
+ }
+ }
+ return url_;
}
const url::Origin& IOSPasswordManagerDriver::GetLastCommittedOrigin() const {
diff --git a/components/password_manager/ios/password_manager_driver_bridge.h b/components/password_manager/ios/password_manager_driver_bridge.h
index 0f7785f0..2e39cb6 100644
--- a/components/password_manager/ios/password_manager_driver_bridge.h
+++ b/components/password_manager/ios/password_manager_driver_bridge.h
@@ -16,13 +16,9 @@
struct PasswordFormGenerationData;
} // namespace autofill
-class GURL;
-
// C++ to ObjC bridge for methods of PasswordManagerDriver.
@protocol PasswordManagerDriverBridge
-@property(readonly, nonatomic) const GURL& lastCommittedURL;
-
// Prepares fill data with given password form data.
// This method calls suggestions helper's processWithPasswordFormFillData.
- (void)processPasswordFormFillData:
diff --git a/components/password_manager/ios/shared_password_controller.mm b/components/password_manager/ios/shared_password_controller.mm
index fee8df8..6c6bc86 100644
--- a/components/password_manager/ios/shared_password_controller.mm
+++ b/components/password_manager/ios/shared_password_controller.mm
@@ -807,11 +807,7 @@
return autofill::FillingProduct::kPassword;
}
-#pragma mark - PasswordManagerDriverDelegate
-
-- (const GURL&)lastCommittedURL {
- return _webState ? _webState->GetLastCommittedURL() : GURL::EmptyGURL();
-}
+#pragma mark - PasswordManagerDriverBridge
- (void)processPasswordFormFillData:
(const autofill::PasswordFormFillData&)formData
diff --git a/components/password_manager/ios/ios_password_manager_driver_unittest.mm b/components/password_manager/ios/ios_password_manager_driver_unittest.mm
index 4db63be..95da1fe7 100644
--- a/components/password_manager/ios/ios_password_manager_driver_unittest.mm
+++ b/components/password_manager/ios/ios_password_manager_driver_unittest.mm
@@ -4,6 +4,8 @@
#import "components/password_manager/ios/ios_password_manager_driver.h"
+#import <string_view>
+
#import "base/functional/callback_helpers.h"
#import "base/memory/raw_ptr.h"
#import "base/memory/scoped_refptr.h"
@@ -23,6 +25,8 @@
#import "testing/platform_test.h"
#import "third_party/ocmock/OCMock/OCMock.h"
#import "third_party/ocmock/gtest_support.h"
+#import "url/gurl.h"
+#import "url/origin.h"
using ::autofill::AutofillJavaScriptFeature;
using ::base::SysNSStringToUTF8;
@@ -30,21 +34,13 @@
using ::testing::_;
using ::testing::Return;
-// This is a workaround for returning const GURL&, for which .andReturn and
-// .andReturnValue don’t work.
-@interface URLGetter : NSObject
+namespace {
-- (const GURL&)lastCommittedURL;
-
-@end
-
-@implementation URLGetter
-
-- (const GURL&)lastCommittedURL {
- return GURL::EmptyGURL();
-}
-
-@end
+// URLs used by frames across multiple tests.
+constexpr std::string_view kMainFrameUrl = "https://example.com/login";
+constexpr std::string_view kMainFrameOriginUrl = "https://example.com/";
+constexpr std::string_view kSubframeUrl = "https://subframe.example.com/iframe";
+constexpr std::string_view kSubframeOriginUrl = "https://subframe.example.com/";
class MockPasswordManagerClient
: public password_manager::StubPasswordManagerClient {
@@ -59,6 +55,8 @@
(const, override));
};
+} // namespace
+
class IOSPasswordManagerDriverTest : public PlatformTest {
public:
IOSPasswordManagerDriverTest() : PlatformTest() {
@@ -69,31 +67,37 @@
web_state_.SetWebFramesManager(content_world,
std::move(web_frames_manager));
- auto web_frame = web::FakeWebFrame::Create(SysNSStringToUTF8(@"main-frame"),
- /*is_main_frame=*/true);
- auto web_frame2 = web::FakeWebFrame::Create(SysNSStringToUTF8(@"frame"),
- /*is_main_frame=*/false);
- web::WebFrame* frame = web_frame.get();
- web::WebFrame* frame2 = web_frame2.get();
- web_frames_manager_->AddWebFrame(std::move(web_frame));
- web_frames_manager_->AddWebFrame(std::move(web_frame2));
+ auto main_frame = web::FakeWebFrame::Create(
+ "main-frame",
+ /*is_main_frame=*/true, url::Origin::Create(GURL(kMainFrameOriginUrl)));
+ main_frame->set_url(GURL(kMainFrameUrl));
+ auto sub_frame = web::FakeWebFrame::Create(
+ "frame",
+ /*is_main_frame=*/false, url::Origin::Create(GURL(kSubframeOriginUrl)));
+ sub_frame->set_url(GURL(kSubframeUrl));
+ web_state_.SetCurrentURL(GURL(kMainFrameUrl));
+ web::WebFrame* main_frame_ptr = main_frame.get();
+ web::WebFrame* sub_frame_ptr = sub_frame.get();
+ web_frames_manager_->AddWebFrame(std::move(main_frame));
+ web_frames_manager_->AddWebFrame(std::move(sub_frame));
password_controller_ = OCMStrictClassMock([SharedPasswordController class]);
IOSPasswordManagerDriverFactory::CreateForWebState(
&web_state_, password_controller_, &password_manager_);
- driver_ = IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
- &web_state_, frame);
- driver2_ = IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
- &web_state_, frame2);
+ main_frame_driver_ =
+ IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
+ &web_state_, main_frame_ptr);
+ subframe_driver_ = IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
+ &web_state_, sub_frame_ptr);
}
protected:
raw_ptr<web::FakeWebFramesManager, DanglingUntriaged> web_frames_manager_;
web::FakeWebState web_state_;
- raw_ptr<IOSPasswordManagerDriver> driver_;
- raw_ptr<IOSPasswordManagerDriver> driver2_;
+ raw_ptr<IOSPasswordManagerDriver> main_frame_driver_;
+ raw_ptr<IOSPasswordManagerDriver> subframe_driver_;
id password_controller_;
testing::StrictMock<MockPasswordManagerClient> password_manager_client_;
PasswordManager password_manager_ =
@@ -102,27 +106,121 @@
// Tests that the drivers have the correct ids.
TEST_F(IOSPasswordManagerDriverTest, GetId) {
- ASSERT_EQ(driver_->GetId(), password_manager::DriverId(1));
- ASSERT_EQ(driver2_->GetId(), password_manager::DriverId(2));
+ ASSERT_EQ(main_frame_driver_->GetId(), password_manager::DriverId(1));
+ ASSERT_EQ(subframe_driver_->GetId(), password_manager::DriverId(2));
}
// Tests the IsInPrimaryMainFrame method.
TEST_F(IOSPasswordManagerDriverTest, IsInPrimaryMainFrame) {
- ASSERT_TRUE(driver_->IsInPrimaryMainFrame());
- ASSERT_FALSE(driver2_->IsInPrimaryMainFrame());
+ ASSERT_TRUE(main_frame_driver_->IsInPrimaryMainFrame());
+ ASSERT_FALSE(subframe_driver_->IsInPrimaryMainFrame());
+}
+
+// Tests the GetLastCommittedURL and GetLastCommittedOrigin methods.
+TEST_F(IOSPasswordManagerDriverTest, GetLastCommittedURLAndOrigin) {
+ EXPECT_EQ(main_frame_driver_->GetLastCommittedURL(), GURL(kMainFrameUrl));
+ EXPECT_EQ(main_frame_driver_->GetLastCommittedOrigin(),
+ url::Origin::Create(GURL(kMainFrameOriginUrl)));
+ EXPECT_EQ(subframe_driver_->GetLastCommittedURL(), GURL(kSubframeUrl));
+ EXPECT_EQ(subframe_driver_->GetLastCommittedOrigin(),
+ url::Origin::Create(GURL(kSubframeOriginUrl)));
+}
+
+// Tests that GetLastCommittedURL falls back to the origin URL when the frame
+// URL is empty and the origin is not opaque.
+TEST_F(IOSPasswordManagerDriverTest, GetLastCommittedURLFallbackToOrigin) {
+ static constexpr std::string_view kFallbackOriginUrl =
+ "https://fallback.example.com/";
+ auto empty_url_frame = web::FakeWebFrame::Create(
+ "empty-url-frame",
+ /*is_main_frame=*/false, url::Origin::Create(GURL(kFallbackOriginUrl)));
+ empty_url_frame->set_url(GURL());
+ web::WebFrame* frame_ptr = empty_url_frame.get();
+ web_frames_manager_->AddWebFrame(std::move(empty_url_frame));
+
+ auto* driver = IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
+ &web_state_, frame_ptr);
+ ASSERT_TRUE(driver);
+ EXPECT_EQ(driver->GetLastCommittedURL(), GURL(kFallbackOriginUrl));
+}
+
+// Tests that GetLastCommittedURL does not fall back to the origin when the
+// frame URL is empty and the origin is opaque.
+TEST_F(IOSPasswordManagerDriverTest, GetLastCommittedURLOpaqueOrigin) {
+ auto opaque_frame =
+ web::FakeWebFrame::Create("opaque-frame",
+ /*is_main_frame=*/false, url::Origin());
+ opaque_frame->set_url(GURL());
+ web::WebFrame* frame_ptr = opaque_frame.get();
+ web_frames_manager_->AddWebFrame(std::move(opaque_frame));
+
+ auto* driver = IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
+ &web_state_, frame_ptr);
+ ASSERT_TRUE(driver);
+ EXPECT_EQ(driver->GetLastCommittedURL(), GURL::EmptyGURL());
+}
+
+// Tests that GetLastCommittedURL falls back to the cached URL for the main
+// frame when WebState's last committed URL is empty.
+TEST_F(IOSPasswordManagerDriverTest, GetLastCommittedURLMainFrameFallback) {
+ // Set empty URL to the webstate to force fallback to the frame URL.
+ web_state_.SetCurrentURL(GURL());
+ EXPECT_EQ(main_frame_driver_->GetLastCommittedURL(), GURL(kMainFrameUrl));
+}
+
+// Tests that GetLastCommittedURL returns the cached URL when WebState is
+// destroyed while the driver outlives it.
+TEST_F(IOSPasswordManagerDriverTest, GetLastCommittedURLWhenWebStateDestroyed) {
+ static constexpr std::string_view kWebStateUrl =
+ "https://example.com/current";
+ auto web_state = std::make_unique<web::FakeWebState>();
+ auto web_frames_manager = std::make_unique<web::FakeWebFramesManager>();
+ auto web_frame = web::FakeWebFrame::Create(
+ "main-frame",
+ /*is_main_frame=*/true, url::Origin::Create(GURL(kMainFrameOriginUrl)));
+ web_frame->set_url(GURL(kMainFrameUrl));
+ web::WebFrame* frame_ptr = web_frame.get();
+ web_frames_manager->AddWebFrame(std::move(web_frame));
+ web::ContentWorld content_world =
+ AutofillJavaScriptFeature::GetInstance()->GetSupportedContentWorld();
+ web_state->SetWebFramesManager(content_world, std::move(web_frames_manager));
+ web_state->SetCurrentURL(GURL(kWebStateUrl));
+
+ IOSPasswordManagerDriverFactory::CreateForWebState(
+ web_state.get(), password_controller_, &password_manager_);
+
+ scoped_refptr<IOSPasswordManagerDriver> retainable_driver =
+ IOSPasswordManagerDriverFactory::GetRetainableDriver(web_state.get(),
+ frame_ptr);
+
+ // Returns the WebState URL while it is alive.
+ ASSERT_TRUE(retainable_driver);
+ EXPECT_EQ(retainable_driver->GetLastCommittedURL(), GURL(kWebStateUrl));
+
+ // Destroy the WebState.
+ base::WeakPtr<web::WebState> weak_web_state = web_state->GetWeakPtr();
+ ASSERT_TRUE(weak_web_state);
+ web_state.reset();
+ ASSERT_FALSE(weak_web_state);
+
+ // Verify that the driver returns the WebFrame URL after the WebState is
+ // destroyed.
+ ASSERT_TRUE(retainable_driver);
+ EXPECT_EQ(retainable_driver->GetLastCommittedURL(), GURL(kMainFrameUrl));
}
// Tests the PropagateFillDataOnParsingCompletion method.
TEST_F(IOSPasswordManagerDriverTest, PropagateFillDataOnParsingCompletion) {
autofill::PasswordFormFillData form_data;
- OCMExpect([[password_controller_ ignoringNonObjectArgs]
- processPasswordFormFillData:form_data
- forFrameId:""
- isMainFrame:driver_->IsInPrimaryMainFrame()
- forSecurityOrigin:driver_->security_origin()])
- .andCompareObjectAtIndex(driver_->web_frame_id(), 1);
- driver_->PropagateFillDataOnParsingCompletion(form_data);
+ OCMExpect(
+ [[password_controller_ ignoringNonObjectArgs]
+ processPasswordFormFillData:form_data
+ forFrameId:""
+ isMainFrame:main_frame_driver_->IsInPrimaryMainFrame()
+ forSecurityOrigin:main_frame_driver_->security_origin()])
+ .andCompareObjectAtIndex(main_frame_driver_->web_frame_id(), 1);
+ main_frame_driver_->PropagateFillDataOnParsingCompletion(form_data);
EXPECT_OCMOCK_VERIFY(password_controller_);
}
@@ -133,7 +231,7 @@
OCMExpect([[password_controller_ ignoringNonObjectArgs]
onNoSavedCredentialsWithFrameId:""])
.andCompareObjectAtIndex(main_frame_id, 0);
- driver_->InformNoSavedCredentials(
+ main_frame_driver_->InformNoSavedCredentials(
/*should_show_popup_without_passwords=*/false);
EXPECT_OCMOCK_VERIFY(password_controller_);
@@ -163,13 +261,6 @@
.Times(3)
.WillRepeatedly(Return(true));
- // Set a last commited URL eligible for generation.
- URLGetter* url_getter = [URLGetter alloc];
- for (int i = 0; i < 3; ++i) {
- OCMStub([password_controller_ lastCommittedURL])
- .andCall(url_getter, @selector(lastCommittedURL));
- }
-
// Set other expected calls on the controller when an eligible form for
// generation is found.
autofill::PasswordFormGenerationData form;
@@ -180,8 +271,8 @@
// Inform the driver that 2 forms eligible for generation were found. The
// driver doesn't know yet at this point whether proactive generation can also
// be used.
- driver_->FormEligibleForGenerationFound(form);
- driver_->FormEligibleForGenerationFound(form);
+ main_frame_driver_->FormEligibleForGenerationFound(form);
+ main_frame_driver_->FormEligibleForGenerationFound(form);
// Inform that there are no saved credentials for the site so proactive
// generation is set up on the forms eligible for generation.
@@ -195,7 +286,7 @@
forFrameId:""]);
OCMExpect([[password_controller_ ignoringNonObjectArgs]
onNoSavedCredentialsWithFrameId:""]);
- driver_->InformNoSavedCredentials(
+ main_frame_driver_->InformNoSavedCredentials(
/*should_show_popup_without_passwords=*/false);
// Inform the driver again that an eligible form for generation was found.
@@ -204,7 +295,7 @@
OCMExpect([[password_controller_ ignoringNonObjectArgs]
attachListenersForPasswordGenerationFields:form
forFrameId:""]);
- driver_->FormEligibleForGenerationFound(form);
+ main_frame_driver_->FormEligibleForGenerationFound(form);
EXPECT_OCMOCK_VERIFY(password_controller_);
}
... (file diff truncated)
Regression Test / PoC
diff --git a/components/password_manager/ios/ios_password_manager_driver_unittest.mm b/components/password_manager/ios/ios_password_manager_driver_unittest.mm
index 4db63be..95da1fe7 100644
--- a/components/password_manager/ios/ios_password_manager_driver_unittest.mm
+++ b/components/password_manager/ios/ios_password_manager_driver_unittest.mm
@@ -4,6 +4,8 @@
#import "components/password_manager/ios/ios_password_manager_driver.h"
+#import <string_view>
+
#import "base/functional/callback_helpers.h"
#import "base/memory/raw_ptr.h"
#import "base/memory/scoped_refptr.h"
@@ -23,6 +25,8 @@
#import "testing/platform_test.h"
#import "third_party/ocmock/OCMock/OCMock.h"
#import "third_party/ocmock/gtest_support.h"
+#import "url/gurl.h"
+#import "url/origin.h"
using ::autofill::AutofillJavaScriptFeature;
using ::base::SysNSStringToUTF8;
@@ -30,21 +34,13 @@
using ::testing::_;
using ::testing::Return;
-// This is a workaround for returning const GURL&, for which .andReturn and
-// .andReturnValue don’t work.
-@interface URLGetter : NSObject
+namespace {
-- (const GURL&)lastCommittedURL;
-
-@end
-
-@implementation URLGetter
-
-- (const GURL&)lastCommittedURL {
- return GURL::EmptyGURL();
-}
-
-@end
+// URLs used by frames across multiple tests.
+constexpr std::string_view kMainFrameUrl = "https://example.com/login";
+constexpr std::string_view kMainFrameOriginUrl = "https://example.com/";
+constexpr std::string_view kSubframeUrl = "https://subframe.example.com/iframe";
+constexpr std::string_view kSubframeOriginUrl = "https://subframe.example.com/";
class MockPasswordManagerClient
: public password_manager::StubPasswordManagerClient {
@@ -59,6 +55,8 @@
(const, override));
};
+} // namespace
+
class IOSPasswordManagerDriverTest : public PlatformTest {
public:
IOSPasswordManagerDriverTest() : PlatformTest() {
@@ -69,31 +67,37 @@
web_state_.SetWebFramesManager(content_world,
std::move(web_frames_manager));
- auto web_frame = web::FakeWebFrame::Create(SysNSStringToUTF8(@"main-frame"),
- /*is_main_frame=*/true);
- auto web_frame2 = web::FakeWebFrame::Create(SysNSStringToUTF8(@"frame"),
- /*is_main_frame=*/false);
- web::WebFrame* frame = web_frame.get();
- web::WebFrame* frame2 = web_frame2.get();
- web_frames_manager_->AddWebFrame(std::move(web_frame));
- web_frames_manager_->AddWebFrame(std::move(web_frame2));
+ auto main_frame = web::FakeWebFrame::Create(
+ "main-frame",
+ /*is_main_frame=*/true, url::Origin::Create(GURL(kMainFrameOriginUrl)));
+ main_frame->set_url(GURL(kMainFrameUrl));
+ auto sub_frame = web::FakeWebFrame::Create(
+ "frame",
+ /*is_main_frame=*/false, url::Origin::Create(GURL(kSubframeOriginUrl)));
+ sub_frame->set_url(GURL(kSubframeUrl));
+ web_state_.SetCurrentURL(GURL(kMainFrameUrl));
+ web::WebFrame* main_frame_ptr = main_frame.get();
+ web::WebFrame* sub_frame_ptr = sub_frame.get();
+ web_frames_manager_->AddWebFrame(std::move(main_frame));
+ web_frames_manager_->AddWebFrame(std::move(sub_frame));
password_controller_ = OCMStrictClassMock([SharedPasswordController class]);
IOSPasswordManagerDriverFactory::CreateForWebState(
&web_state_, password_controller_, &password_manager_);
- driver_ = IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
- &web_state_, frame);
- driver2_ = IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
- &web_state_, frame2);
+ main_frame_driver_ =
+ IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
+ &web_state_, main_frame_ptr);
+ subframe_driver_ = IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
+ &web_state_, sub_frame_ptr);
}
protected:
raw_ptr<web::FakeWebFramesManager, DanglingUntriaged> web_frames_manager_;
web::FakeWebState web_state_;
- raw_ptr<IOSPasswordManagerDriver> driver_;
- raw_ptr<IOSPasswordManagerDriver> driver2_;
+ raw_ptr<IOSPasswordManagerDriver> main_frame_driver_;
+ raw_ptr<IOSPasswordManagerDriver> subframe_driver_;
id password_controller_;
testing::StrictMock<MockPasswordManagerClient> password_manager_client_;
PasswordManager password_manager_ =
@@ -102,27 +106,121 @@
// Tests that the drivers have the correct ids.
TEST_F(IOSPasswordManagerDriverTest, GetId) {
- ASSERT_EQ(driver_->GetId(), password_manager::DriverId(1));
- ASSERT_EQ(driver2_->GetId(), password_manager::DriverId(2));
+ ASSERT_EQ(main_frame_driver_->GetId(), password_manager::DriverId(1));
+ ASSERT_EQ(subframe_driver_->GetId(), password_manager::DriverId(2));
}
// Tests the IsInPrimaryMainFrame method.
TEST_F(IOSPasswordManagerDriverTest, IsInPrimaryMainFrame) {
- ASSERT_TRUE(driver_->IsInPrimaryMainFrame());
- ASSERT_FALSE(driver2_->IsInPrimaryMainFrame());
+ ASSERT_TRUE(main_frame_driver_->IsInPrimaryMainFrame());
+ ASSERT_FALSE(subframe_driver_->IsInPrimaryMainFrame());
+}
+
+// Tests the GetLastCommittedURL and GetLastCommittedOrigin methods.
+TEST_F(IOSPasswordManagerDriverTest, GetLastCommittedURLAndOrigin) {
+ EXPECT_EQ(main_frame_driver_->GetLastCommittedURL(), GURL(kMainFrameUrl));
+ EXPECT_EQ(main_frame_driver_->GetLastCommittedOrigin(),
+ url::Origin::Create(GURL(kMainFrameOriginUrl)));
+ EXPECT_EQ(subframe_driver_->GetLastCommittedURL(), GURL(kSubframeUrl));
+ EXPECT_EQ(subframe_driver_->GetLastCommittedOrigin(),
+ url::Origin::Create(GURL(kSubframeOriginUrl)));
+}
+
+// Tests that GetLastCommittedURL falls back to the origin URL when the frame
+// URL is empty and the origin is not opaque.
+TEST_F(IOSPasswordManagerDriverTest, GetLastCommittedURLFallbackToOrigin) {
+ static constexpr std::string_view kFallbackOriginUrl =
+ "https://fallback.example.com/";
+ auto empty_url_frame = web::FakeWebFrame::Create(
+ "empty-url-frame",
+ /*is_main_frame=*/false, url::Origin::Create(GURL(kFallbackOriginUrl)));
+ empty_url_frame->set_url(GURL());
+ web::WebFrame* frame_ptr = empty_url_frame.get();
+ web_frames_manager_->AddWebFrame(std::move(empty_url_frame));
+
+ auto* driver = IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
+ &web_state_, frame_ptr);
+ ASSERT_TRUE(driver);
+ EXPECT_EQ(driver->GetLastCommittedURL(), GURL(kFallbackOriginUrl));
+}
+
+// Tests that GetLastCommittedURL does not fall back to the origin when the
+// frame URL is empty and the origin is opaque.
+TEST_F(IOSPasswordManagerDriverTest, GetLastCommittedURLOpaqueOrigin) {
+ auto opaque_frame =
+ web::FakeWebFrame::Create("opaque-frame",
+ /*is_main_frame=*/false, url::Origin());
+ opaque_frame->set_url(GURL());
+ web::WebFrame* frame_ptr = opaque_frame.get();
+ web_frames_manager_->AddWebFrame(std::move(opaque_frame));
+
+ auto* driver = IOSPasswordManagerDriverFactory::FromWebStateAndWebFrame(
+ &web_state_, frame_ptr);
+ ASSERT_TRUE(driver);
+ EXPECT_EQ(driver->GetLastCommittedURL(), GURL::EmptyGURL());
+}
+
+// Tests that GetLastCommittedURL falls back to the cached URL for the main
+// frame when WebState's last committed URL is empty.
+TEST_F(IOSPasswordManagerDriverTest, GetLastCommittedURLMainFrameFallback) {
+ // Set empty URL to the webstate to force fallback to the frame URL.
+ web_state_.SetCurrentURL(GURL());
+ EXPECT_EQ(main_frame_driver_->GetLastCommittedURL(), GURL(kMainFrameUrl));
+}
+
+// Tests that GetLastCommittedURL returns the cached URL when WebState is
+// destroyed while the driver outlives it.
+TEST_F(IOSPasswordManagerDriverTest, GetLastCommittedURLWhenWebStateDestroyed) {
+ static constexpr std::string_view kWebStateUrl =
+ "https://example.com/current";
+ auto web_state = std::make_unique<web::FakeWebState>();
+ auto web_frames_manager = std::make_unique<web::FakeWebFramesManager>();
+ auto web_frame = web::FakeWebFrame::Create(
+ "main-frame",
+ /*is_main_frame=*/true, url::Origin::Create(GURL(kMainFrameOriginUrl)));
+ web_frame->set_url(GURL(kMainFrameUrl));
+ web::WebFrame* frame_ptr = web_frame.get();
+ web_frames_manager->AddWebFrame(std::move(web_frame));
+ web::ContentWorld content_world =
+ AutofillJavaScriptFeature::GetInstance()->GetSupportedContentWorld();
+ web_state->SetWebFramesManager(content_world, std::move(web_frames_manager));
+ web_state->SetCurrentURL(GURL(kWebStateUrl));
+
+ IOSPasswordManagerDriverFactory::CreateForWebState(
+ web_state.get(), password_controller_, &password_manager_);
+
+ scoped_refptr<IOSPasswordManagerDriver> retainable_driver =
+ IOSPasswordManagerDriverFactory::GetRetainableDriver(web_state.get(),
+ frame_ptr);
+
+ // Returns the WebState URL while it is alive.
+ ASSERT_TRUE(retainable_driver);
+ EXPECT_EQ(retainable_driver->GetLastCommittedURL(), GURL(kWebStateUrl));
+
+ // Destroy the WebState.
+ base::WeakPtr<web::WebState> weak_web_state = web_state->GetWeakPtr();
+ ASSERT_TRUE(weak_web_state);
+ web_state.reset();
+ ASSERT_FALSE(weak_web_state);
+
+ // Verify that the driver returns the WebFrame URL after the WebState is
+ // destroyed.
+ ASSERT_TRUE(retainable_driver);
+ EXPECT_EQ(retainable_driver->GetLastCommittedURL(), GURL(kMainFrameUrl));
}
// Tests the PropagateFillDataOnParsingCompletion method.
TEST_F(IOSPasswordManagerDriverTest, PropagateFillDataOnParsingCompletion) {
autofill::PasswordFormFillData form_data;
- OCMExpect([[password_controller_ ignoringNonObjectArgs]
- processPasswordFormFillData:form_data
- forFrameId:""
- isMainFrame:driver_->IsInPrimaryMainFrame()
- forSecurityOrigin:driver_->security_origin()])
- .andCompareObjectAtIndex(driver_->web_frame_id(), 1);
- driver_->PropagateFillDataOnParsingCompletion(form_data);
+ OCMExpect(
+ [[password_controller_ ignoringNonObjectArgs]
+ processPasswordFormFillData:form_data
+ forFrameId:""
+ isMainFrame:main_frame_driver_->IsInPrimaryMainFrame()
+ forSecurityOrigin:main_frame_driver_->security_origin()])
+ .andCompareObjectAtIndex(main_frame_driver_->web_frame_id(), 1);
+ main_frame_driver_->PropagateFillDataOnParsingCompletion(form_data);
EXPECT_OCMOCK_VERIFY(password_controller_);
}
@@ -133,7 +231,7 @@
OCMExpect([[password_controller_ ignoringNonObjectArgs]
onNoSavedCredentialsWithFrameId:""])
.andCompareObjectAtIndex(main_frame_id, 0);
- driver_->InformNoSavedCredentials(
+ main_frame_driver_->InformNoSavedCredentials(
/*should_show_popup_without_passwords=*/false);
EXPECT_OCMOCK_VERIFY(password_controller_);
@@ -163,13 +261,6 @@
.Times(3)
.WillRepeatedly(Return(true));
- // Set a last commited URL eligible for generation.
- URLGetter* url_getter = [URLGetter alloc];
- for (int i = 0; i < 3; ++i) {
- OCMStub([password_controller_ lastCommittedURL])
- .andCall(url_getter, @selector(lastCommittedURL));
- }
-
// Set other expected calls on the controller when an eligible form for
// generation is found.
autofill::PasswordFormGenerationData form;
@@ -180,8 +271,8 @@
// Inform the driver that 2 forms eligible for generation were found. The
// driver doesn't know yet at this point whether proactive generation can also
// be used.
- driver_->FormEligibleForGenerationFound(form);
- driver_->FormEligibleForGenerationFound(form);
+ main_frame_driver_->FormEligibleForGenerationFound(form);
+ main_frame_driver_->FormEligibleForGenerationFound(form);
// Inform that there are no saved credentials for the site so proactive
// generation is set up on the forms eligible for generation.
@@ -195,7 +286,7 @@
forFrameId:""]);
OCMExpect([[password_controller_ ignoringNonObjectArgs]
onNoSavedCredentialsWithFrameId:""]);
- driver_->InformNoSavedCredentials(
+ main_frame_driver_->InformNoSavedCredentials(
/*should_show_popup_without_passwords=*/false);
// Inform the driver again that an eligible form for generation was found.
@@ -204,7 +295,7 @@
OCMExpect([[password_controller_ ignoringNonObjectArgs]
attachListenersForPasswordGenerationFields:form
forFrameId:""]);
- driver_->FormEligibleForGenerationFound(form);
+ main_frame_driver_->FormEligibleForGenerationFound(form);
EXPECT_OCMOCK_VERIFY(password_controller_);
}
@@ -233,12 +324,6 @@
.Times(21)
.WillRepeatedly(Return(true));
... (truncated)
Original Bug Report
Cross-origin iframe poisoning of password manager usernames on iOS
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.
Overview: On iOS, IOSPasswordManagerDriver incorrectly reports the main frame’s URL for all subframes. This breaks origin isolation, allowing a cross-origin iframe to inject a potential username into the Password Manager’s cache for the top-level origin. When the user subsequently submits a password on the top-level site, they may be prompted to save their password paired with the attacker’s chosen username.
Affected files:
components/password_manager/ios/ios_password_manager_driver.mmcomponents/password_manager/ios/shared_password_controller.mmcomponents/autofill/ios/form_util/resources/form_handlers.tscomponents/password_manager/core/browser/password_manager.ccios/chrome/browser/autofill/form_input_accessory/coordinator/form_input_accessory_mediator.mmcomponents/autofill/ios/form_util/form_activity_tab_helper.mm
Estimated timestamp from git blame: 2023-11-30
Description
There is a potential vulnerability in the iOS Password Manager where cross-origin iframes can poison the Username-First Flow (UFF) cache for the top-level origin. This occurs due to an incorrect implementation of GetLastCommittedURL() in the iOS password manager driver.
In components/password_manager/ios/ios_password_manager_driver.mm, IOSPasswordManagerDriver::GetLastCommittedURL() delegates its call to the bridge object (SharedPasswordController). The implementation of lastCommittedURL in SharedPasswordController returns _webState->GetLastCommittedURL(). Because WebState represents the entire tab, this always returns the URL of the top-level main frame, even when the driver instance belongs to a subframe.
When a user interacts with a potential username field (e.g., a field with autocomplete="username"), the event is processed by PasswordManager::UpdateStateOnUserInput. This function calls PasswordManager::OnUserModifiedNonPasswordField, which uses driver->GetLastCommittedURL() to determine the sign-on realm for caching the candidate in the global possible_usernames_ cache. Because the iOS driver incorrectly returns the main frame’s URL, any input activity within a cross-origin iframe is incorrectly associated with the main frame’s security realm.
Furthermore, the iOS event handling path (SharedPasswordController::checkIfSuggestionsAvailableForForm:hasUserGesture:webState:completionHandler:) does not filter out synthetic input events based on the hasUserGesture parameter, allowing an attacker to programmatically trigger this caching behavior.
Potential Exploit Scenario
Note: These are suggested steps; we have not verified them with a working proof-of-concept.
- A victim navigates to a legitimate site (e.g.,
https://victim.example) that utilizes a Username-First Flow login (username and password on separate steps/forms). - The victim site embeds a cross-origin iframe controlled by an attacker (e.g.,
https://evil.example/poison.html). - The attacker’s iframe contains a hidden input field:
<input type="text" autocomplete="username" id="attacker_u">. - The attacker’s script programmatically sets the value of this field to an attacker-controlled username and dispatches a synthetic
inputevent. - Chromium’s injected
form_handlers.tscaptures the event and forwards it to the browser process.SharedPasswordControllerpasses it toPasswordManager::UpdateStateOnUserInput, ignoring that the event lacks a user gesture. - The Password Manager observes the
autocomplete="username"attribute and callsOnUserModifiedNonPasswordField. - The Password Manager calculates the realm using
driver->GetLastCommittedURL(). Because of the iOS-specific bug, this evaluates tohttps://victim.example/instead of the iframe’s true origin. - The attacker’s username is stored in the
possible_usernames_cache under the victim site’s realm. - The victim navigates to the password-entry step on
victim.exampleand submits their legitimate password. - The
PasswordFormManagersearches thepossible_usernames_cache for a matching realm. It finds the attacker’s poisoned entry. Because the entry originated from a field withautocomplete="username", it is given high priority (kUsernameAutocomplete) and overrides the empty username field in the submitted form. - Chrome presents the password save/update prompt, offering to save the victim’s real password paired with the attacker’s chosen username. If the user accepts, their credential for
victim.exampleis corrupted.
Suggested Fix
- Correct URL Delegation: Modify
IOSPasswordManagerDriverto store and return the actual URL or origin of theweb::WebFrameit represents. The driver constructor already receives aweb::WebFrame*and caches thesecurity_origin_; this should be utilized to determine the correct realm for subframes. - Enforce User Gestures: Modify
SharedPasswordControllerorPasswordManager::UpdateStateOnUserInputto reject synthetic events (wherehasUserGestureis false) when updating security-sensitive state like thepossible_usernames_cache.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.