diff --git a/chrome/renderer/autofill/password_generation_agent_browsertest.cc b/chrome/renderer/autofill/password_generation_agent_browsertest.cc
index 1e660a126c707..9ba78d86f4f4e 100644
--- a/chrome/renderer/autofill/password_generation_agent_browsertest.cc
+++ b/chrome/renderer/autofill/password_generation_agent_browsertest.cc
@@ -37,12 +37,14 @@
#include "services/service_manager/public/cpp/interface_provider.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
+#include "third_party/blink/public/common/renderer_preferences/renderer_preferences.h"
#include "third_party/blink/public/platform/web_string.h"
#include "third_party/blink/public/web/web_document.h"
#include "third_party/blink/public/web/web_element.h"
#include "third_party/blink/public/web/web_frame_widget.h"
#include "third_party/blink/public/web/web_input_element.h"
#include "third_party/blink/public/web/web_local_frame.h"
+#include "third_party/blink/public/web/web_view.h"
#include "ui/events/keycodes/keyboard_codes.h"
using autofill::mojom::FocusedFieldType;
@@ -1582,5 +1584,107 @@ TEST_F(PasswordGenerationAgentTest, AutomaticSuggestionOnHasBeenPasswordField) {
ExpectAutomaticGenerationAvailable(kPasswordElementId, kAvailable);
}
+// =====================================================================
+// Regression test: UAF in GeneratedPasswordAccepted() when JS re-enters
+// MaybeCreateCurrentGenerationItem() during the password_elements_ loop.
+//
+// On Android Chrome (default flags) a JS .focus() during transient user
+// activation synchronously routes through AutofillAgent::FocusedElementChanged
+// to ShowPasswordGenerationSuggestions(). On desktop this path is disabled
+// (focus_requires_scroll = true). This fixture flips
+// uses_platform_autofill so that the AutofillAgent under test is created
+// with focus_requires_scroll = false, enabling the same synchronous path.
+// =====================================================================
+class PasswordGenerationAgentReentrantUafTest
+ : public PasswordGenerationAgentTest {
+ public:
+ void RegisterMainFrameRemoteInterfaces() override {
+ blink::RendererPreferences prefs =
+ GetMainRenderFrame()->GetWebView()->GetRendererPreferences();
+ prefs.uses_platform_autofill = true;
+ GetMainRenderFrame()->GetWebView()->SetRendererPreferences(prefs);
+ PasswordGenerationAgentTest::RegisterMainFrameRemoteInterfaces();
+ }
+};
+
+constexpr char kTwoGenerationFormsHTML[] =
+ "
"
+ ""
+ "x
"
+ "";
+
+TEST_F(PasswordGenerationAgentReentrantUafTest,
+ GeneratedPasswordAcceptedReentrantFree) {
+ // Allow any driver/client mojo calls during the re-entrant chain; the
+ // crash happens before they're flushed but TearDown may still see them.
+ EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_))
+ .Times(AnyNumber());
+ EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(_))
+ .Times(AnyNumber());
+ EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword(_, _))
+ .Times(AnyNumber());
+#if !BUILDFLAG(IS_ANDROID)
+ EXPECT_CALL(fake_pw_client_, GenerationElementLostFocus())
+ .Times(AnyNumber());
+ EXPECT_CALL(fake_pw_client_, FrameWasScrolled()).Times(AnyNumber());
+ EXPECT_CALL(fake_pw_client_, ShowPasswordEditingPopup).Times(AnyNumber());
+ EXPECT_CALL(fake_pw_client_, PasswordGenerationRejectedByTyping())
+ .Times(AnyNumber());
+#endif
+
+ LoadHTMLWithUserGesture(kTwoGenerationFormsHTML);
+
+ // Mark both password fields as generation-eligible (simulates two
+ // FoundFormEligibleForGeneration messages from the browser).
+ SetFoundFormEligibleForGeneration(password_generation_,
+ GetMainFrame()->GetDocument(),
+ /*new_password_id=*/"pwA",
+ /*confirm_password_id=*/nullptr);
+ SetFoundFormEligibleForGeneration(password_generation_,
+ GetMainFrame()->GetDocument(),
+ /*new_password_id=*/"pwB",
+ /*confirm_password_id=*/nullptr);
+
+ // Click pwA: grants transient user activation and (via the
+ // FocusedElementChanged -> ShowSuggestions path) creates
+ // current_generation_item_ for pwA.
+ ASSERT_TRUE(SimulateElementClick("pwA"));
+
+ // Install an `input` handler on pwA. SetAutofillValue() inside the
+ // GeneratedPasswordAccepted() loop dispatches `input` synchronously, so
+ // this runs while the loop holds a reference into
+ // current_generation_item_->password_elements_.
+ // 1. pwA.value = '' -> generation_element_.Value().length() == 0
+ // 2. txt.focus() -> moves focus off pwA so step 3 is a real change
+ // 3. pwA.focus() -> ShowPasswordGenerationSuggestions(pwA)
+ // -> PasswordNoLongerGenerated()
+ // -> password_is_generated_ = false (disarm)
+ // 4. pwB.focus() -> MaybeCreateCurrentGenerationItem(pwB)
+ // -> guard at :859-862 falls through
+ // -> current_generation_item_ reassigned
+ // -> old password_elements_ buffer freed
+ ExecuteJavaScriptForTests(
+ "var ran = false;"
+ "document.getElementById('pwA').addEventListener('input', function() {"
+ " if (ran) return; ran = true;"
+ " document.getElementById('pwA').value = '';"
+ " document.getElementById('txt').focus();"
+ " document.getElementById('pwA').focus();"
+ " document.getElementById('pwB').focus();"
+ "});");
+
+ // Enter the vulnerable loop. SetAutofillValue(pwA) at :408 dispatches the
+ // input event above; on return `password_element` and `this` inside
+ // WebFormControlElement::SetAutofillValue point into freed heap.
+ // Under ASAN this reports heap-use-after-free.
+ password_generation_->GeneratedPasswordAccepted(u"random_password");
+}
+
} // namespace
} // namespace autofill