1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-13 14:53:33 +00:00

Autofill/pm 25597 plex password generation (#16997)

* Correctly fill generated passwords and current password on plex.tv

* Correctly fill generated passwords and current password on plex.tv

* Leave existing forEach

* Add tests for changes
This commit is contained in:
Jeffrey Holland
2025-11-20 16:31:05 +01:00
committed by GitHub
parent a5caa194cd
commit e23b2d0c98
6 changed files with 259 additions and 18 deletions

View File

@@ -3286,6 +3286,9 @@ describe("OverlayBackground", () => {
pageDetails: [pageDetailsForTab],
fillNewPassword: true,
allowTotpAutofill: true,
focusedFieldForm: undefined,
focusedFieldOpid: undefined,
inlineMenuFillType: undefined,
});
expect(overlayBackground["inlineMenuCiphers"].entries()).toStrictEqual(
new Map([
@@ -3680,6 +3683,9 @@ describe("OverlayBackground", () => {
pageDetails: [overlayBackground["pageDetailsForTab"][sender.tab.id].get(sender.frameId)],
fillNewPassword: true,
allowTotpAutofill: false,
focusedFieldForm: undefined,
focusedFieldOpid: undefined,
inlineMenuFillType: InlineMenuFillTypes.PasswordGeneration,
});
});
});

View File

@@ -1177,6 +1177,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
allowTotpAutofill: true,
focusedFieldForm: this.focusedFieldData?.focusedFieldForm,
focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid,
inlineMenuFillType: this.focusedFieldData?.inlineMenuFillType,
});
if (totpCode) {
@@ -1863,6 +1864,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
allowTotpAutofill: false,
focusedFieldForm: this.focusedFieldData?.focusedFieldForm,
focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid,
inlineMenuFillType: InlineMenuFillTypes.PasswordGeneration,
});
globalThis.setTimeout(async () => {

View File

@@ -6,6 +6,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AutofillMessageCommand } from "../../enums/autofill-message.enums";
import { InlineMenuFillType } from "../../enums/autofill-overlay.enum";
import AutofillField from "../../models/autofill-field";
import AutofillForm from "../../models/autofill-form";
import AutofillPageDetails from "../../models/autofill-page-details";
@@ -30,6 +31,7 @@ export interface AutoFillOptions {
autoSubmitLogin?: boolean;
focusedFieldForm?: string;
focusedFieldOpid?: string;
inlineMenuFillType?: InlineMenuFillType;
}
export interface FormData {
@@ -49,6 +51,7 @@ export interface GenerateFillScriptOptions {
tabUrl: string;
defaultUriMatch: UriMatchStrategySetting;
focusedFieldOpid?: string;
inlineMenuFillType?: InlineMenuFillType;
}
export type CollectPageDetailsResponseMessage = {

View File

@@ -1118,6 +1118,12 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private async setQualifiedLoginFillType(autofillFieldData: AutofillField) {
// Check if this is a current password field in a password change form
if (this.inlineMenuFieldQualificationService.isUpdateCurrentPasswordField(autofillFieldData)) {
autofillFieldData.inlineMenuFillType = InlineMenuFillTypes.CurrentPasswordUpdate;
return;
}
autofillFieldData.inlineMenuFillType = CipherType.Login;
autofillFieldData.showPasskeys = autofillFieldData.autoCompleteType.includes("webauthn");

View File

@@ -44,6 +44,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums";
import { InlineMenuFillTypes } from "../enums/autofill-overlay.enum";
import { AutofillPort } from "../enums/autofill-port.enum";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
@@ -103,6 +104,15 @@ describe("AutofillService", () => {
beforeEach(() => {
configService = mock<ConfigService>();
configService.getFeatureFlag$.mockImplementation(() => of(false));
// Initialize domainSettingsService BEFORE it's used
domainSettingsService = new DefaultDomainSettingsService(
fakeStateProvider,
policyService,
accountService,
);
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
scriptInjectorService = new BrowserScriptInjectorService(
domainSettingsService,
platformUtilsService,
@@ -141,12 +151,6 @@ describe("AutofillService", () => {
userNotificationsSettings,
messageListener,
);
domainSettingsService = new DefaultDomainSettingsService(
fakeStateProvider,
policyService,
accountService,
);
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
jest.spyOn(BrowserApi, "tabSendMessage");
});
@@ -2077,6 +2081,193 @@ describe("AutofillService", () => {
});
});
describe("given password generation with inlineMenuFillType", () => {
beforeEach(() => {
pageDetails.forms = undefined;
pageDetails.fields = []; // Clear fields to start fresh
options.inlineMenuFillType = InlineMenuFillTypes.PasswordGeneration;
options.cipher.login.totp = null; // Disable TOTP for these tests
});
it("includes all password fields from the same form when filling with password generation", async () => {
const newPasswordField = createAutofillFieldMock({
opid: "new-password",
type: "password",
form: "validFormId",
elementNumber: 2,
});
const confirmPasswordField = createAutofillFieldMock({
opid: "confirm-password",
type: "password",
form: "validFormId",
elementNumber: 3,
});
pageDetails.fields.push(newPasswordField, confirmPasswordField);
options.focusedFieldOpid = newPasswordField.opid;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(filledFields[newPasswordField.opid]).toBeDefined();
expect(filledFields[confirmPasswordField.opid]).toBeDefined();
});
it("finds username field for the first password field when generating passwords", async () => {
const newPasswordField = createAutofillFieldMock({
opid: "new-password",
type: "password",
form: "validFormId",
elementNumber: 2,
});
pageDetails.fields.push(newPasswordField);
options.focusedFieldOpid = newPasswordField.opid;
jest.spyOn(autofillService as any, "findUsernameField");
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["findUsernameField"]).toHaveBeenCalledWith(
pageDetails,
expect.objectContaining({ opid: newPasswordField.opid }),
false,
false,
true,
);
});
it("does not include password fields from different forms", async () => {
const formAPasswordField = createAutofillFieldMock({
opid: "form-a-password",
type: "password",
form: "formA",
elementNumber: 1,
});
const formBPasswordField = createAutofillFieldMock({
opid: "form-b-password",
type: "password",
form: "formB",
elementNumber: 2,
});
pageDetails.fields = [formAPasswordField, formBPasswordField];
options.focusedFieldOpid = formAPasswordField.opid;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(filledFields[formAPasswordField.opid]).toBeDefined();
expect(filledFields[formBPasswordField.opid]).toBeUndefined();
});
});
describe("given current password update with inlineMenuFillType", () => {
beforeEach(() => {
pageDetails.forms = undefined;
pageDetails.fields = []; // Clear fields to start fresh
options.inlineMenuFillType = InlineMenuFillTypes.CurrentPasswordUpdate;
options.cipher.login.totp = null; // Disable TOTP for these tests
});
it("includes all password fields from the same form when updating current password", async () => {
const currentPasswordField = createAutofillFieldMock({
opid: "current-password",
type: "password",
form: "validFormId",
elementNumber: 1,
});
const newPasswordField = createAutofillFieldMock({
opid: "new-password",
type: "password",
form: "validFormId",
elementNumber: 2,
});
const confirmPasswordField = createAutofillFieldMock({
opid: "confirm-password",
type: "password",
form: "validFormId",
elementNumber: 3,
});
pageDetails.fields.push(currentPasswordField, newPasswordField, confirmPasswordField);
options.focusedFieldOpid = currentPasswordField.opid;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(filledFields[currentPasswordField.opid]).toBeDefined();
expect(filledFields[newPasswordField.opid]).toBeDefined();
expect(filledFields[confirmPasswordField.opid]).toBeDefined();
});
it("includes all password fields from the same form without TOTP", async () => {
const currentPasswordField = createAutofillFieldMock({
opid: "current-password",
type: "password",
form: "validFormId",
elementNumber: 1,
});
const newPasswordField = createAutofillFieldMock({
opid: "new-password",
type: "password",
form: "validFormId",
elementNumber: 2,
});
pageDetails.fields.push(currentPasswordField, newPasswordField);
options.focusedFieldOpid = currentPasswordField.opid;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(filledFields[currentPasswordField.opid]).toBeDefined();
expect(filledFields[newPasswordField.opid]).toBeDefined();
});
it("does not include password fields from different forms during password update", async () => {
const formAPasswordField = createAutofillFieldMock({
opid: "form-a-password",
type: "password",
form: "formA",
elementNumber: 1,
});
const formBPasswordField = createAutofillFieldMock({
opid: "form-b-password",
type: "password",
form: "formB",
elementNumber: 2,
});
pageDetails.fields = [formAPasswordField, formBPasswordField];
options.focusedFieldOpid = formAPasswordField.opid;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(filledFields[formAPasswordField.opid]).toBeDefined();
expect(filledFields[formBPasswordField.opid]).toBeUndefined();
});
});
describe("given a set of page details that does not contain a password field", () => {
let emailField: AutofillField;
let emailFieldView: FieldView;
@@ -3140,12 +3331,16 @@ describe("AutofillService", () => {
"example.com",
"exampleapp.com",
]);
domainSettingsService.equivalentDomains$ = of([["not-example.com"]]);
const pageUrl = "https://subdomain.example.com";
const tabUrl = "https://www.not-example.com";
const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl });
generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(false);
// Mock getUrlEquivalentDomains to return the expected domains
jest
.spyOn(domainSettingsService, "getUrlEquivalentDomains")
.mockReturnValue(of(equivalentDomains));
const result = await autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions);
expect(generateFillScriptOptions.cipher.login.matchesUri).toHaveBeenCalledWith(

View File

@@ -52,6 +52,7 @@ import { ScriptInjectorService } from "../../platform/services/abstractions/scri
// eslint-disable-next-line no-restricted-imports
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums";
import { InlineMenuFillTypes } from "../enums/autofill-overlay.enum";
import { AutofillPort } from "../enums/autofill-port.enum";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
@@ -452,6 +453,7 @@ export default class AutofillService implements AutofillServiceInterface {
tabUrl: tab.url,
defaultUriMatch: defaultUriMatch,
focusedFieldOpid: options.focusedFieldOpid,
inlineMenuFillType: options.inlineMenuFillType,
});
if (!fillScript || !fillScript.script || !fillScript.script.length) {
@@ -971,26 +973,53 @@ export default class AutofillService implements AutofillServiceInterface {
if (passwordFields.length && !passwords.length) {
// in the event that password fields exist but weren't processed within form elements.
// select matching password if focused, otherwise first in prioritized list. for username, use focused field if it matches, otherwise find field before password.
const passwordFieldToUse = focusedField
? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0]
: prioritizedPasswordFields[0];
const isPasswordGeneration =
options.inlineMenuFillType === InlineMenuFillTypes.PasswordGeneration;
const isCurrentPasswordUpdate =
options.inlineMenuFillType === InlineMenuFillTypes.CurrentPasswordUpdate;
if (passwordFieldToUse) {
passwords.push(passwordFieldToUse);
// For password generation or current password update, include all password fields from the same form
// This ensures we have access to all fields regardless of their login/registration classification
if ((isPasswordGeneration || isCurrentPasswordUpdate) && focusedField) {
// Add all password fields from the same form as the focused field
const focusedFieldForm = focusedField.form;
if (login.username && passwordFieldToUse.elementNumber > 0) {
username = getUsernameForPassword(passwordFieldToUse, true);
// Check both login and registration fields to ensure we get all password fields
const allPasswordFields = [...loginPasswordFields, ...registrationPasswordFields];
allPasswordFields.forEach((passField) => {
if (passField.form === focusedFieldForm) {
passwords.push(passField);
}
});
}
// If we didn't add any passwords above (either not password generation/update or no matching fields),
// select matching password if focused, otherwise first in prioritized list.
if (!passwords.length) {
const passwordFieldToUse = focusedField
? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0]
: prioritizedPasswordFields[0];
if (passwordFieldToUse) {
passwords.push(passwordFieldToUse);
}
}
// Handle username and TOTP for the first password field
const firstPasswordField = passwords[0];
if (firstPasswordField) {
if (login.username && firstPasswordField.elementNumber > 0) {
username = getUsernameForPassword(firstPasswordField, true);
if (username) {
usernames.set(username.opid, username);
}
}
if (options.allowTotpAutofill && login.totp && passwordFieldToUse.elementNumber > 0) {
if (options.allowTotpAutofill && login.totp && firstPasswordField.elementNumber > 0) {
totp =
isFocusedTotpField && passwordMatchesFocused(passwordFieldToUse)
isFocusedTotpField && passwordMatchesFocused(firstPasswordField)
? focusedField
: this.findTotpField(pageDetails, passwordFieldToUse, false, false, true);
: this.findTotpField(pageDetails, firstPasswordField, false, false, true);
if (totp) {
totps.push(totp);
}