Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in iOSWebView
DescriptionMemory corruption in iOSWebView
ComponentChromium
Sub-componentiOS
Bug ClassMemory Corruption
Tracker523705767
Fix commit57a304be5c5c (chromium/src) +1219/-116
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
ios/web_view/internal/autofill/cwv_autofill_controller.mm
modified
switch
ios/web_view/internal/autofill/cwv_autofill_controller.mm
modified

Files Changed

  • ios/web_view/internal/autofill/cwv_autofill_prefs.h
  • ios/web_view/internal/autofill/cwv_autofill_controller_unittest.mm
  • ios/web_view/internal/cwv_preferences_unittest.mm
  • ios/web_view/public/cwv_defines.h
  • ios/web_view/internal/autofill/cwv_autofill_controller.mm
  • ios/web_view/internal/autofill/cwv_autofill_prefs.mm
  • ios/web_view/internal/cwv_preferences.mm
  • ios/web_view/public/cwv_preferences.h
  • ios/web_view/test/BUILD.gn
From 57a304be5c5c1cbb3fc760d7b73d7620b9d385c0 Mon Sep 17 00:00:00 2001
From: Alexis Hétu <[email protected]>
Date: Mon, 28 Sep 2026 09:52:15 -0700
Subject: [PATCH] [iOS] Scope CWVAutofillController form activity to the focused field

CWVAutofillController caches the form, field, frame and user gesture
of the last form activity, and targets focus traversal, credit card
fetches and suggestion queries at it. Any frame could overwrite parts
of that cache, so activity from an unrelated field or a child frame
could redirect a later suggestion query for the focused field, making
it inherit renderer IDs that point somewhere else.

Only a focus event now establishes the cached target, and only events
reported by that same frame and field may refresh its value or
gesture. A suggestion query for any other field falls back to empty
renderer IDs, which match no form, rather than silently reusing the
focused field's. Activity is also dropped when it is malformed, when
the committed URL cannot be trusted, or when it cannot be attributed
to a frame; the latter two additionally clear the cache, since a
target that can no longer be attributed to a page or frame must not
be reused.

Two behaviors observable to //ios/web_view embedders change:

- CWVAutofillControllerDelegate is no longer notified of focus, input
  or blur events dropped by the guards above. Activity with no
  originating frame was previously reported with an empty frameID.
- The reported user gesture now reflects the most recent interaction
  with the focused field rather than the focus event alone. Typing
  into a field focused by a script dispatched event now counts as
  user initiated, while a blur or an event from another field no
  longer sets the bit.

Bug: 523705767
Change-Id: I715430c16f44cf1cfa25be11bb499c186fbf2fd2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8396510
Reviewed-by: Carlos Acosta <[email protected]>
Reviewed-by: Rohit Rao <[email protected]>
Reviewed-by: Vincent Boisselle <[email protected]>
Commit-Queue: Alexis Hétu <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1706208}
---
diff --git a/ios/web_view/internal/autofill/cwv_autofill_controller.mm b/ios/web_view/internal/autofill/cwv_autofill_controller.mm
index c82ecc6..be4920d 100644
--- a/ios/web_view/internal/autofill/cwv_autofill_controller.mm
+++ b/ios/web_view/internal/autofill/cwv_autofill_controller.mm
@@ -4,6 +4,7 @@
 
 #import <algorithm>
 #import <memory>
+#import <optional>
 #import <string>
 #import <vector>
 
@@ -73,6 +74,7 @@
 #import "ios/web_view/public/cwv_autofill_controller_delegate.h"
 #import "ios/web_view/public/cwv_web_view.h"
 #import "net/base/apple/url_conversions.h"
+#import "url/gurl.h"
 
 using autofill::FieldRendererId;
 using autofill::FormData;
@@ -124,6 +126,19 @@
   }
   return CWVAutofillProgressDialogTypeUnspecified;
 }
+
+// State tracked for the form field that last received focus.
+struct FocusedFieldState {
+  NSString* form_name = nil;
+  NSString* field_identifier = nil;
+  NSString* frame_id = nil;
+  NSString* typed_value = nil;
+  ActivityType activity_type = ActivityType::kUnknown;
+  FormRendererId form_renderer_id;
+  FieldRendererId field_renderer_id;
+  BOOL has_user_gesture = NO;
+};
+
 }  // namespace
 
 @implementation CWVAutofillController {
@@ -179,18 +194,16 @@
   std::unique_ptr<autofill::FormActivityObserverBridge>
       _formActivityObserverBridge;
 
-  NSString* _lastFormActivityFormName;
-  NSString* _lastFormActivityFieldIdentifier;
-  NSString* _lastFormActivityFrameID;
-  std::string _lastFormActivityWebFrameID;
-  NSString* _lastFormActivityTypedValue;
-  ActivityType _lastFormActivityType;
-  FormRendererId _lastFormActivityFormRendererID;
-  FieldRendererId _lastFormActivityFieldRendererID;
-  BOOL _lastFormActivityHasUserGesture;
+  // State tracked for the form field that last received focus, or
+  // `std::nullopt` when no field is tracked.
+  std::optional<FocusedFieldState> _focusedField;
 
   // YES if CWVAutofillController is hardened against WebState destruction.
   BOOL _safeLifecycleEnabled;
+
+  // YES if CWVAutofillController scopes cached form activity to the focused
+  // field.
+  BOOL _scopedFormActivityEnabled;
 }
 
 @synthesize delegate = _delegate;
@@ -261,6 +274,8 @@
     _webState = webState;
     _safeLifecycleEnabled =
         ios_web_view::IsAutofillSafeLifecycleEnabled(prefService);
+    _scopedFormActivityEnabled =
+        ios_web_view::IsAutofillScopedFormActivityEnabled(prefService);
 
     _autofillAgent = autofillAgent;
 
@@ -351,21 +366,37 @@
         }
       };
 
-  BOOL hasUserGesture = NO;
-  if ([formName isEqualToString:_lastFormActivityFormName] &&
-      [fieldIdentifier isEqualToString:_lastFormActivityFieldIdentifier] &&
-      [frameID isEqualToString:_lastFormActivityFrameID]) {
-    hasUserGesture = _lastFormActivityHasUserGesture;
-  }
-
+  // The cached form activity state describes the field that last received
+  // focus. Reusing it for a query aimed at a different field or frame would let
+  // that query inherit the focused field's renderer IDs and redirect the fill,
+  // so fall back to empty values, which match no form.
+  BOOL isFocusedField = [self isFocusedFieldWithFormName:formName
+                                         fieldIdentifier:fieldIdentifier
+                                                 frameID:frameID];
+  BOOL useFocusedFieldState =
+      _scopedFormActivityEnabled ? isFocusedField : _focusedField.has_value();
+  // The gesture has always required an exact match; only the renderer IDs,
+  // activity type and typed value fall back to the legacy unscoped behavior.
+  BOOL hasUserGesture = isFocusedField && _focusedField->has_user_gesture;
   autofill::FormRendererId targetFormRendererID =
-      _lastFormActivityFormRendererID;
+      useFocusedFieldState ? _focusedField->form_renderer_id
+                           : autofill::FormRendererId();
   autofill::FieldRendererId targetFieldRendererID =
-      _lastFormActivityFieldRendererID;
-
-  _lastFormActivityFormName = formName;
-  _lastFormActivityFrameID = frameID;
-  _lastFormActivityFieldIdentifier = fieldIdentifier;
+      useFocusedFieldState ? _focusedField->field_renderer_id
+                           : autofill::FieldRendererId();
+  ActivityType targetActivityType = useFocusedFieldState
+                                        ? _focusedField->activity_type
+                                        : ActivityType::kUnknown;
+  NSString* targetTypedValue =
+      useFocusedFieldState ? _focusedField->typed_value : nil;
+  if (!_scopedFormActivityEnabled) {
+    if (!_focusedField) {
+      _focusedField.emplace();
+    }
+    _focusedField->form_name = formName;
+    _focusedField->frame_id = frameID;
+    _focusedField->field_identifier = fieldIdentifier;
+  }
 
   // Construct query.
   FormSuggestionProviderQuery* formQuery = [[FormSuggestionProviderQuery alloc]
@@ -374,8 +405,8 @@
        fieldIdentifier:fieldIdentifier
        fieldRendererID:targetFieldRendererID
              fieldType:(FieldType)fieldType
-                  type:_lastFormActivityType
-            typedValue:_lastFormActivityTypedValue
+                  type:targetActivityType
+            typedValue:targetTypedValue
                frameID:frameID
           onlyPassword:NO];
   // It is necessary to call |checkIfSuggestionsAvailableForForm| before
@@ -480,18 +511,21 @@
                   payload:autofill::Suggestion::Guid(card.internalCard->guid())
            requiresReauth:NO];
 
-  [_autofillAgent didSelectSuggestion:suggestion
-                              atIndex:index
-                                 form:_lastFormActivityFormName
-                       formRendererID:_lastFormActivityFormRendererID
-                      fieldIdentifier:_lastFormActivityFieldIdentifier
-                      fieldRendererID:_lastFormActivityFieldRendererID
-                              frameID:_lastFormActivityFrameID
-                    completionHandler:^{
-                      if (completionHandler) {
-                        completionHandler();
-                      }
-                    }];
+  [_autofillAgent
+      didSelectSuggestion:suggestion
+                  atIndex:index
+                     form:_focusedField ? _focusedField->form_name : nil
+           formRendererID:_focusedField ? _focusedField->form_renderer_id
+                                        : autofill::FormRendererId()
+          fieldIdentifier:_focusedField ? _focusedField->field_identifier : nil
+          fieldRendererID:_focusedField ? _focusedField->field_renderer_id
+                                        : autofill::FieldRendererId()
+                  frameID:_focusedField ? _focusedField->frame_id : nil
+        completionHandler:^{
+          if (completionHandler) {
+            completionHandler();
+          }
+        }];
 }
 
 - (void)fetchFullCardDetailsForCard:(CWVCreditCard*)card
@@ -506,9 +540,7 @@
     }
     return;
   }
-  web::WebFrame* frame = autofill::AutofillJavaScriptFeature::GetInstance()
-                             ->GetWebFramesManager(_webState)
-                             ->GetFrameWithId(_lastFormActivityWebFrameID);
+  web::WebFrame* frame = [self frameForFocusedField];
 
   if (!frame) {
     if (completionHandler) {
@@ -550,11 +582,7 @@
 }
 
 - (void)focusPreviousField {
-  web::WebFramesManager* framesManager =
-      autofill::AutofillJavaScriptFeature::GetInstance()->GetWebFramesManager(
-          _webState);
-  web::WebFrame* frame =
-      framesManager->GetFrameWithId(_lastFormActivityWebFrameID);
+  web::WebFrame* frame = [self frameForFocusedField];
 
   if (!frame) {
     return;
@@ -565,11 +593,7 @@
 }
 
 - (void)focusNextField {
-  web::WebFramesManager* framesManager =
-      autofill::AutofillJavaScriptFeature::GetInstance()->GetWebFramesManager(
-          _webState);
-  web::WebFrame* frame =
-      framesManager->GetFrameWithId(_lastFormActivityWebFrameID);
+  web::WebFrame* frame = [self frameForFocusedField];
 
   if (!frame) {
     return;
@@ -581,11 +605,11 @@
 
 - (void)checkIfPreviousAndNextFieldsAreAvailableForFocusWithCompletionHandler:
     (void (^)(BOOL previous, BOOL next))completionHandler {
-  web::WebFramesManager* framesManager =
-      autofill::AutofillJavaScriptFeature::GetInstance()->GetWebFramesManager(
-          _webState);
-  web::WebFrame* frame =
-      framesManager->GetFrameWithId(_lastFormActivityWebFrameID);
+  web::WebFrame* frame = [self frameForFocusedField];
+
+  if (!frame) {
+    return;
+  }
 
   autofill::SuggestionControllerJavaScriptFeature::GetInstance()
       ->FetchPreviousAndNextElementsPresenceInFrame(
@@ -962,25 +986,87 @@
                     inFrame:(web::WebFrame*)frame {
   DCHECK_EQ(_webState, webState);
 
-  std::string frame_id = frame ? frame->GetFrameId() : "";
+  if (_scopedFormActivityEnabled) {
+    // A malformed payload carries no trustworthy target, so it is dropped
+    // without touching the cache. Resetting here instead would let any frame
+    // clear a legitimate focus target just by reporting an incomplete event.
+    if (params.input_missing) {
+      return;
+    }
+
+    // The two guards below do reset, unlike the one above: losing the page
+    // identity or the originating frame means the cached target can no longer
+    // be attributed to anything, so keeping it risks aiming a later suggestion
+    // query or focus traversal at a stale field.
+    std::optional<GURL> pageURL = webState->GetLastCommittedURLIfTrusted();
+    if (!pageURL || !pageURL->is_valid()) {
+      [self resetLastFormActivity];
+      return;
+    }
+
+    if (!frame) {
+      [self resetLastFormActivity];
+      return;
+    }
+  }
 
   NSString* nsFormName = base::SysUTF8ToNSString(params.form_name);
-  _lastFormActivityFormRendererID = params.form_renderer_id;
   NSString* nsFieldIdentifier =
       base::SysUTF8ToNSString(params.field_identifier);
-  _lastFormActivityFieldRendererID = params.field_renderer_id;
   FieldType fieldType = params.field_type;
-  NSString* nsFrameID = base::SysUTF8ToNSString(frame_id);
+  NSString* nsFrameID =
+      frame ? base::SysUTF8ToNSString(frame->GetFrameId()) : @"";
   NSString* nsValue = base::SysUTF8ToNSString(params.value);
   BOOL userInitiated = params.has_user_gesture;
 
-  _lastFormActivityWebFrameID = frame_id;
-  _lastFormActivityTypedValue = nsValue;
-  _lastFormActivityType = params.type;
-  _lastFormActivityHasUserGesture = userInitiated;
-  _lastFormActivityFormName = nsFormName;
-  _lastFormActivityFieldIdentifier = nsFieldIdentifier;
-  _lastFormActivityFrameID = nsFrameID;
+  if (!_scopedFormActivityEnabled || params.type == ActivityType::kFocus) {
+    _focusedField = FocusedFieldState{
+        .form_name = nsFormName,
+        .field_identifier = nsFieldIdentifier,
+        .frame_id = nsFrameID,
+        .typed_value = nsValue,
+        .activity_type = params.type,
+        .form_renderer_id = params.form_renderer_id,
+        .field_renderer_id = params.field_renderer_id,
+        .has_user_gesture = userInitiated,
+    };
+  } else if ([self isFocusedFieldWithFormName:nsFormName
+                              fieldIdentifier:nsFieldIdentifier
+                                      frameID:nsFrameID]) {
+    // Only the field that currently holds focus may refresh the cached value. A
+    // value reported by any other frame or field must not end up attached to
+    // the suggestion query issued for the focused field.
+    switch (params.type) {
+      case ActivityType::kInput:
+      case ActivityType::kKeyUp:
+        _focusedField->typed_value = nsValue;
+        _focusedField->activity_type = params.type;
+        // Editing the focused field is at least as strong a signal of user
+        // intent as focusing it, so a trusted edit may promote the cached
+        // gesture. This matters for a field focused by a script dispatched
+        // event, which reports no gesture even though the user then really
+        // types into it. The value is assigned rather than OR'ed so that a
+        // later script synthesized edit demotes the bit again.
+        _focusedField->has_user_gesture = userInitiated;
+        break;
+      case ActivityType::kChange:
+      case ActivityType::kBlur:
+        // Change and blur events can fire when leaving a field (e.g. when the
+        // user clicks elsewhere on the page), so they refresh the value and
+        // activity type without promoting `has_user_gesture`.
+        _focusedField->typed_value = nsValue;
+        _focusedField->activity_type = params.type;
+        break;
+      case ActivityType::kFocus:
+        NOTREACHED();
+      case ActivityType::kFormChanged:
+      case ActivityType::kUnknown:
+        break;
+    }
+  } else if (_scopedFormActivityEnabled) {
+    return;
+  }
+
   if (params.type == ActivityType::kFocus) {
     if ([_delegate respondsToSelector:@selector
                    (autofillController:
@@ -1120,6 +1206,7 @@
   _webStateObserverBridge.reset();
   _passwordManager.reset();
   _passwordManagerClient.reset();
+  [self resetLastFormActivity];
   _webState = nullptr;
 }
 
@@ -1298,6 +1385,37 @@
 
 #pragma mark - Private
 
+// Resets state tracked from the last form activity.
+- (void)resetLastFormActivity {
+  _focusedField.reset();
+}
+
+// Returns the `WebFrame` for the field that last received focus, or `nullptr`
+// if no field is tracked or the frame no longer exists.
+- (web::WebFrame*)frameForFocusedField {
+  if (!_focusedField || !_webState) {
+    return nullptr;
+  }
+  return autofill::AutofillJavaScriptFeature::GetInstance()
+      ->GetWebFramesManager(_webState)
+      ->GetFrameWithId(base::SysNSStringToUTF8(_focusedField->frame_id));
+}
+
+// Returns YES if `formName`, `fieldIdentifier`, and `frameID` identify the
+// currently focused field. State cached for the focused field must never be
+// applied to, or updated from, another field or frame: doing so would let any
+// frame on the page retarget autofill at the field the user is actually
+// interacting with. Returns NO when no field is focused (`_focusedField` is
+// `std::nullopt`).
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/web_view/internal/autofill/cwv_autofill_controller_unittest.mm b/ios/web_view/internal/autofill/cwv_autofill_controller_unittest.mm
index d15b0e5c..967eaa41 100644
--- a/ios/web_view/internal/autofill/cwv_autofill_controller_unittest.mm
+++ b/ios/web_view/internal/autofill/cwv_autofill_controller_unittest.mm
@@ -5,7 +5,9 @@
 #import <Foundation/Foundation.h>
 
 #import <memory>
+#import <optional>
 
+#import "base/functional/callback_helpers.h"
 #import "base/ios/block_types.h"
 #import "base/run_loop.h"
 #import "base/strings/sys_string_conversions.h"
@@ -30,9 +32,12 @@
 #import "components/autofill/ios/browser/autofill_java_script_feature.h"
 #import "components/autofill/ios/browser/fake_autofill_agent.h"
 #import "components/autofill/ios/browser/form_suggestion.h"
+#import "components/autofill/ios/browser/form_suggestion_provider_query.h"
+#import "components/autofill/ios/browser/suggestion_controller_java_script_feature.h"
 #import "components/autofill/ios/browser/test_autofill_client_ios.h"
 #import "components/autofill/ios/form_util/form_activity_params.h"
 #import "components/autofill/ios/form_util/form_activity_tab_helper.h"
+#import "components/autofill/ios/form_util/form_handlers_java_script_feature.h"
 #import "components/autofill/ios/form_util/test_form_activity_tab_helper.h"
 #import "components/password_manager/core/browser/leak_detection_dialog_utils.h"
 #import "components/password_manager/core/browser/password_manager.h"
@@ -49,6 +54,7 @@
 #import "ios/web/public/test/fakes/fake_web_frame.h"
 #import "ios/web/public/test/fakes/fake_web_frames_manager.h"
 #import "ios/web/public/test/fakes/fake_web_state.h"
+#import "ios/web/public/test/js_test_util.h"
 #import "ios/web/public/test/web_test.h"
 #import "ios/web_view/internal/autofill/cwv_autofill_controller+testing.h"
 #import "ios/web_view/internal/autofill/cwv_autofill_controller_internal.h"
@@ -76,9 +82,68 @@
 using base::test::ios::kWaitForActionTimeout;
 using base::test::ios::WaitUntilConditionOrTimeout;
 
+// Records the names of the form activity notifications it receives, so that
+// tests can assert on their absence and see exactly which ones fired.
+@interface FakeFormActivityDelegate : NSObject <CWVAutofillControllerDelegate>
+
+@property(nonatomic, readonly) NSArray<NSString*>* receivedNotifications;
+
+@end
+
+@implementation FakeFormActivityDelegate {
+  NSMutableArray<NSString*>* _receivedNotifications;
+}
+
+- (instancetype)init {
+  self = [super init];
+  if (self) {
+    _receivedNotifications = [NSMutableArray array];
+  }
+  return self;
+}
+
+- (NSArray<NSString*>*)receivedNotifications {
+  return [_receivedNotifications copy];
+}
+
+#pragma mark - CWVAutofillControllerDelegate
+
+- (void)autofillController:(CWVAutofillController*)autofillController
+    didFocusOnFieldWithIdentifier:(NSString*)fieldIdentifier
+                        fieldType:(NSInteger)fieldType
+                         formName:(NSString*)formName
+                          frameID:(NSString*)frameID
+                            value:(NSString*)value
+                    userInitiated:(BOOL)userInitiated {
+  [_receivedNotifications addObject:@"didFocusOnField"];
+}
+
+- (void)autofillController:(CWVAutofillController*)autofillController
+    didInputInFieldWithIdentifier:(NSString*)fieldIdentifier
+                        fieldType:(NSInteger)fieldType
+                         formName:(NSString*)formName
+                          frameID:(NSString*)frameID
+                            value:(NSString*)value
+                    userInitiated:(BOOL)userInitiated {
+  [_receivedNotifications addObject:@"didInputInField"];
+}
+
+- (void)autofillController:(CWVAutofillController*)autofillController
+    didBlurOnFieldWithIdentifier:(NSString*)fieldIdentifier
+                       fieldType:(NSInteger)fieldType
+                        formName:(NSString*)formName
+                         frameID:(NSString*)frameID
+                           value:(NSString*)value
+                   userInitiated:(BOOL)userInitiated {
+  [_receivedNotifications addObject:@"didBlurOnField"];
+}
+
+@end
+
 namespace ios_web_view {
 namespace {
 
+constexpr std::string_view kTestURL = "https://example.com/";
 NSString* const kTestFormName = @"FormName";
 FormRendererId kTestFormRendererID = FormRendererId(0);
 NSString* const kTestFieldIdentifier = @"FieldIdentifier";
@@ -106,18 +171,41 @@
   base::WeakPtrFactory<MockOtpUnmaskDelegate> weak_factory_{this};
 };
 
-class CWVAutofillControllerTest : public web::WebTest {
+// A `FakeWebState` whose committed URL can be made untrusted. Plain
+// `FakeWebState` always reports its URL as trusted, which leaves the untrusted
+// branch of `-webState:didRegisterFormActivity:inFrame:` unreachable.
+class FakeWebStateWithUntrustableURL : public web::FakeWebState {
+ public:
+  ~FakeWebStateWithUntrustableURL() override = default;
+
+  void set_url_trusted(bool url_trusted) { url_trusted_ = url_trusted; }
+
+  // web::WebState:
+  std::optional<GURL> GetLastCommittedURLIfTrusted() const override {
+    if (!url_trusted_) {
+      return std::nullopt;
+    }
+    return web::FakeWebState::GetLastCommittedURLIfTrusted();
+  }
+
+ private:
+  bool url_trusted_ = true;
+};
+
+class CWVAutofillControllerTestBase : public web::WebTest {
  protected:
-  CWVAutofillControllerTest() {
+  CWVAutofillControllerTestBase() {
     l10n_util::OverrideLocaleWithCocoaLocale();
     pref_service_.registry()->RegisterBooleanPref(
         password_manager::prefs::kCredentialsEnableService, true);
     pref_service_.registry()->RegisterBooleanPref(
         autofill::prefs::kAutofillProfileEnabled, true);
     ios_web_view::RegisterCWVAutofillPrefs(pref_service_.registry());
+    ios_web_view::SetAutofillScopedFormActivityEnabled(&pref_service_, true);
     RegisterWebViewPasswordManagerPrefs(pref_service_.registry());
 
     web_state_.SetBrowserState(&browser_state_);
+    web_state_.SetCurrentURL(GURL(kTestURL));
 
     frame_id_ = base::SysUTF8ToNSString(web::kMainFakeFrameId);
 
@@ -135,6 +223,20 @@
         [[FakeAutofillAgent alloc] initWithPrefService:&pref_service_
                                               webState:&web_state_];
 
+    password_controller_ = OCMClassMock([SharedPasswordController class]);
+    form_activity_tab_helper_ =
+        std::make_unique<autofill::TestFormActivityTabHelper>(&web_state_);
+  }
+
+  void SetUp() override {
+    web::WebTest::SetUp();
+
+    web::test::OverrideJavaScriptFeatures(
+        &browser_state_,
+        {autofill::AutofillJavaScriptFeature::GetInstance(),
+         autofill::FormHandlersJavaScriptFeature::GetInstance(),
+         autofill::SuggestionControllerJavaScriptFeature::GetInstance()});
+
     auto password_manager_client =
         std::make_unique<WebViewPasswordManagerClient>(
             &web_state_, /*sync_service=*/nullptr, &pref_service_,
@@ -144,7 +246,6 @@
             /*requirements_service=*/nullptr);
     auto password_manager = std::make_unique<password_manager::PasswordManager>(
         password_manager_client.get());
-    password_controller_ = OCMClassMock([SharedPasswordController class]);
     IOSPasswordManagerDriverFactory::CreateForWebState(
         &web_state_, password_controller_, password_manager.get());
     password_manager_client_ = password_manager_client.get();
@@ -162,15 +263,6 @@
               passwordManager:std::move(password_manager)
         passwordManagerClient:std::move(password_manager_client)
            passwordController:password_controller_];
-    form_activity_tab_helper_ =
-        std::make_unique<autofill::TestFormActivityTabHelper>(&web_state_);
-  }
-
-  void SetUp() override {
-    web::WebTest::SetUp();
-
-    OverrideJavaScriptFeatures(
-        {autofill::AutofillJavaScriptFeature::GetInstance()});
   }
 
   void TearDown() override {
@@ -180,13 +272,18 @@
     web::WebTest::TearDown();
   }
 
-  void AddWebFrame(std::unique_ptr<web::WebFrame> frame) {
+  void AddWebFrame(std::unique_ptr<web::FakeWebFrame> frame) {
+    frame->set_browser_state(&browser_state_);
     web_frames_manager_->AddWebFrame(std::move(frame));
   }
 
-  void RegisterFormActivity(bool has_user_gesture = true) {
-    auto frame = web::FakeWebFrame::Create(base::SysNSStringToUTF8(frame_id_),
-                                           /*is_main_frame=*/true, GURL());
+  // Registers a focus event on `kTestFormName` / `kTestFieldIdentifier` in a
+  // newly created main frame, and returns that frame so that callers can post
+  // follow up activity to it.
+  web::WebFrame* RegisterFormActivity(bool has_user_gesture) {
+    auto frame =
+        web::FakeWebFrame::Create(base::SysNSStringToUTF8(frame_id_),
+                                  /*is_main_frame=*/true, GURL(kTestURL));
     web::WebFrame* frame_ptr = frame.get();
     AddWebFrame(std::move(frame));
 
@@ -196,6 +293,90 @@
     params.type = ActivityType::kFocus;
     params.has_user_gesture = has_user_gesture;
     form_activity_tab_helper_->FormActivityRegistered(frame_ptr, params);
+    return frame_ptr;
+  }
+
+  web::WebFrame* RegisterFormActivity() {
+    return RegisterFormActivity(/*has_user_gesture=*/true);
+  }
+
+  // Stubs `password_controller_` to always answer "no suggestions available",
+  // recording the query and gesture it was invoked with in
+  // `last_suggestions_query_` / `last_suggestions_had_user_gesture_`.
+  // `ignoringNonObjectArgs` makes the stub match regardless of the gesture
+  // value, so that tests assert on the recorded gesture instead of the mock
+  // silently failing to match (which would strand the fetch completion).
+  void StubPasswordSuggestionsAvailability() {
+    CWVAutofillControllerTestBase* test = this;
+    OCMStub([password_controller_
+                checkIfSuggestionsAvailableForForm:[OCMArg any]
+                                    hasUserGesture:NO
+                                          webState:&web_state_
+                                 completionHandler:[OCMArg any]])
+        .ignoringNonObjectArgs()
+        .andDo(^(NSInvocation* invocation) {
+          __unsafe_unretained FormSuggestionProviderQuery* query = nil;
+          [invocation getArgument:&query atIndex:2];
+          BOOL has_user_gesture = NO;
+          [invocation getArgument:&has_user_gesture atIndex:3];
+          __unsafe_unretained void (^suggestions_available)(BOOL) = nil;
+          [invocation getArgument:&suggestions_available atIndex:5];
+
+          test->last_suggestions_query_ = query;
+          test->last_suggestions_had_user_gesture_ = has_user_gesture;
+          suggestions_available(NO);
+        });
+  }
+
+  // Fetches suggestions for `fieldIdentifier` on `kTestFormName` in the main
+  // frame and waits for the fetch to complete.
+  void FetchSuggestionsForField(NSString* fieldIdentifier) {
+    base::test::TestFuture<NSArray<CWVAutofillSuggestion*>*> suggestions_future;
+
+    [autofill_controller_
+        fetchSuggestionsForFormWithName:kTestFormName
+                        fieldIdentifier:fieldIdentifier
+                              fieldType:(NSInteger)FieldType::kUnknown
+                                frameID:frame_id_
+                      completionHandler:base::CallbackToBlock(
+                                            suggestions_future.GetCallback())];
+
+    ASSERT_TRUE(suggestions_future.Wait());
+  }
+
+  // Returns activity params of `type` describing the default test field in
+  // `frame`.
+  autofill::FormActivityParams FormActivityParamsForType(ActivityType type,
+                                                         web::WebFrame* frame) {
+    autofill::FormActivityParams params;
+    params.form_name = base::SysNSStringToUTF8(kTestFormName);
+    params.field_identifier = base::SysNSStringToUTF8(kTestFieldIdentifier);
+    params.value = base::SysNSStringToUTF8(kTestFieldValue);
+    params.frame_id = frame ? frame->GetFrameId() : std::string();
+    params.has_user_gesture = true;
+    params.type = type;
+    return params;
+  }
+
+  // Returns activity params of `type` describing the default test field, as
+  // reported by the main frame.
+  autofill::FormActivityParams FormActivityParamsForType(ActivityType type) {
+    autofill::FormActivityParams params =
+        FormActivityParamsForType(type, /*frame=*/nullptr);
+    params.frame_id = web::kMainFakeFrameId;
+    return params;
+  }
+
+  // Registers activity of `type` on the default test field in `frame`.
+  void RegisterFieldActivity(web::WebFrame* frame,
+                             ActivityType type,
+                             NSString* value,
+                             bool has_user_gesture) {
+    autofill::FormActivityParams params =
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Cross-Origin Focus Hijacking in CWVAutofillController via form_changed Events

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: CWVAutofillController unconditionally updates its active frame state upon receiving any form activity event, including gestureless ‘form_changed’ events. A cross-origin iframe can exploit this by mutating its DOM to trigger a ‘form_changed’ event, silently poisoning the controller’s internal state. Subsequent field navigation commands (like ‘Next Field’) from the embedding application will then be misdirected to the attacker’s iframe, allowing potential keystroke interception.

Affected files:

  • ios/web_view/internal/autofill/cwv_autofill_controller.mm

Estimated timestamp from git blame: Unknown (Google3 checkout)

Description

A potential vulnerability exists in CWVAutofillController (part of the ios/web_view API) that allows a cross-origin iframe to hijack focus by poisoning the controller’s internal state. This occurs because the controller updates its tracking of the active frame before validating the type of form activity event it has received.

In ios/web_view/internal/autofill/cwv_autofill_controller.mm, the method -webState:didRegisterFormActivity:inFrame: handles form activity notifications from the renderer process. It unconditionally updates several state variables, most notably _lastFormActivityWebFrameID, using the frame provided in the callback:

- (void)webState:(web::WebState*)webState
    didRegisterFormActivity:(const autofill::FormActivityParams&)params
                    inFrame:(web::WebFrame*)frame {
  // ...
  std::string frame_id = frame ? frame->GetFrameId() : "";
  // ...
  _lastFormActivityWebFrameID = frame_id; // Updated unconditionally
  // ...
  if (params.type == "focus") { ... }
  else if (params.type == "input" || params.type == "keyup") { ... }
  // ...
}

One type of activity event is form_changed, which is generated automatically by a MutationObserver in the renderer when DOM mutations occur on form elements. This event does not require a user gesture.

Because the state update happens before checking the event type, an attacker-controlled cross-origin iframe can trigger this event to overwrite _lastFormActivityWebFrameID with its own frame ID at any time.

Impact

The _lastFormActivityWebFrameID variable is relied upon by the following methods to determine which frame should receive focus navigation commands:

  • focusPreviousField
  • focusNextField
  • checkIfPreviousAndNextFieldsAreAvailableForFocusWithCompletionHandler:

If a third-party iOS application embedding Chromium via ios/web_view provides UI (such as ‘Next’ or ‘Previous’ buttons in a keyboard accessory) that calls these methods, the commands will be directed to the attacker’s iframe instead of the user’s intended frame. This results in cross-origin focus hijacking. A user, believing they are still typing into the top-level form, would inadvertently type their sensitive information directly into the attacker’s frame.

Potential Exploit Steps

Note: These are suggested steps based on code analysis; our tooling agent does not have the ability to run active exploit code.

  1. An attacker hosts a malicious page loaded as a cross-origin iframe within a legitimate site or app view.
  2. The user focuses a legitimate input field in the top-level frame. This correctly sets _lastFormActivityWebFrameID to the top-level frame.
  3. The embedding application displays a keyboard accessory with a “Next” button.
  4. The attacker’s iframe executes JavaScript to mutate its DOM (e.g., document.body.appendChild(document.createElement('input'))).
  5. This mutation triggers a gestureless form_changed event sent from the renderer to the browser process.
  6. CWVAutofillController receives the event and unconditionally overwrites _lastFormActivityWebFrameID with the attacker’s frame ID.
  7. The user taps the “Next” button on the app’s UI.
  8. The app calls [CWVAutofillController focusNextField], which uses the poisoned _lastFormActivityWebFrameID to send focus into the attacker’s iframe.
  9. The attacker’s JavaScript captures subsequent keystrokes via standard input or keydown event listeners.

Modify -webState:didRegisterFormActivity:inFrame: in cwv_autofill_controller.mm to explicitly ignore form_changed events when updating active frame state, or move the state assignments inside the conditional blocks that check for user-initiated events (focus, input, etc.).

  // Ignore form_changed events to prevent gestureless form changes from
  // overwriting the active target web frame ID.
  if (params.type == "form_changed") {
    return;
  }
  
  _lastFormActivityWebFrameID = frame_id;
  // ...

(A similar fix was previously implemented in FormInputAccessoryMediator).

Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

View on issue tracker