1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-28289] Address false-positives of new login save prompts (#17783)

* add values to TotpFieldNames constant

* add totp field check to username field qualification

* handle checking empty string cases

* update tests

* require stored username for new cipher notification prompt

* drop ambiguous token keyword from authoritative TOTP field names constant

* adjust shouldAttemptNotification logic for add and change cases
This commit is contained in:
Jonathan Prusik
2025-12-03 11:46:48 -05:00
committed by GitHub
parent 17ebae11d7
commit 422e527516
4 changed files with 128 additions and 17 deletions

View File

@@ -262,11 +262,30 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
*/ */
private notificationDataIncompleteOnBeforeRequest = (tabId: number) => { private notificationDataIncompleteOnBeforeRequest = (tabId: number) => {
const modifyLoginData = this.modifyLoginCipherFormData.get(tabId); const modifyLoginData = this.modifyLoginCipherFormData.get(tabId);
return (
!modifyLoginData || if (!modifyLoginData) {
!this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Add) || return true;
!this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Change) }
const shouldAttemptAddNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Add,
); );
if (shouldAttemptAddNotification) {
return false;
}
const shouldAttemptChangeNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Change,
);
if (shouldAttemptChangeNotification) {
return false;
}
return false;
}; };
/** /**
@@ -454,15 +473,27 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
modifyLoginData: ModifyLoginCipherFormData, modifyLoginData: ModifyLoginCipherFormData,
notificationType: NotificationType, notificationType: NotificationType,
): boolean => { ): boolean => {
// Intentionally not stripping whitespace characters here as they
// represent user entry.
const usernameFieldHasValue = !!(modifyLoginData?.username || "").length;
const passwordFieldHasValue = !!(modifyLoginData?.password || "").length;
const newPasswordFieldHasValue = !!(modifyLoginData?.newPassword || "").length;
const canBeUserLogin = usernameFieldHasValue && passwordFieldHasValue;
const canBePasswordUpdate = passwordFieldHasValue && newPasswordFieldHasValue;
switch (notificationType) { switch (notificationType) {
// `Add` case included because all forms with cached usernames (from previous
// visits) will appear to be "password only" and otherwise trigger the new login
// save notification.
case NotificationTypes.Add: case NotificationTypes.Add:
return ( // Can be values for nonstored login or account creation
modifyLoginData?.username && !!(modifyLoginData.password || modifyLoginData.newPassword) return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue);
);
case NotificationTypes.Change: case NotificationTypes.Change:
return !!(modifyLoginData.password || modifyLoginData.newPassword); // Can be login with nonstored login changes or account password update
return canBeUserLogin || canBePasswordUpdate;
case NotificationTypes.AtRiskPassword: case NotificationTypes.AtRiskPassword:
return !modifyLoginData.newPassword; return !newPasswordFieldHasValue;
case NotificationTypes.Unlock: case NotificationTypes.Unlock:
// Unlock notifications are handled separately and do not require form data // Unlock notifications are handled separately and do not require form data
return false; return false;

View File

@@ -39,6 +39,7 @@ export class AutoFillConstants {
"otpcode", "otpcode",
"onetimepassword", "onetimepassword",
"security_code", "security_code",
"second-factor",
"twofactor", "twofactor",
"twofa", "twofa",
"twofactorcode", "twofactorcode",

View File

@@ -1603,14 +1603,14 @@ describe("AutofillOverlayContentService", () => {
it("skips triggering submission if a button is not found", async () => { it("skips triggering submission if a button is not found", async () => {
const submitButton = document.querySelector("button"); const submitButton = document.querySelector("button");
submitButton.remove(); submitButton?.remove();
await autofillOverlayContentService.setupOverlayListeners( await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement, autofillFieldElement,
autofillFieldData, autofillFieldData,
pageDetailsMock, pageDetailsMock,
); );
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" })); submitButton?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith( expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith(
"formFieldSubmitted", "formFieldSubmitted",
@@ -1627,7 +1627,7 @@ describe("AutofillOverlayContentService", () => {
pageDetailsMock, pageDetailsMock,
); );
await flushPromises(); await flushPromises();
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" })); submitButton?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith( expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
"formFieldSubmitted", "formFieldSubmitted",
@@ -1641,7 +1641,7 @@ describe("AutofillOverlayContentService", () => {
<div id="shadow-root"></div> <div id="shadow-root"></div>
<button id="button-el">Change Password</button> <button id="button-el">Change Password</button>
</div>`; </div>`;
const shadowRoot = document.getElementById("shadow-root").attachShadow({ mode: "open" }); const shadowRoot = document.getElementById("shadow-root")!.attachShadow({ mode: "open" });
shadowRoot.innerHTML = ` shadowRoot.innerHTML = `
<input type="password" id="password-field-1" placeholder="new password" /> <input type="password" id="password-field-1" placeholder="new password" />
`; `;
@@ -1668,7 +1668,7 @@ describe("AutofillOverlayContentService", () => {
pageDetailsMock, pageDetailsMock,
); );
await flushPromises(); await flushPromises();
buttonElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" })); buttonElement?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith( expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
"formFieldSubmitted", "formFieldSubmitted",
@@ -1716,6 +1716,85 @@ describe("AutofillOverlayContentService", () => {
}); });
}); });
describe("refreshMenuLayerPosition", () => {
it("calls refreshTopLayerPosition on the inline menu content service", () => {
autofillOverlayContentService.refreshMenuLayerPosition();
expect(inlineMenuContentService.refreshTopLayerPosition).toHaveBeenCalled();
});
it("does not throw if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
expect(() => serviceWithoutInlineMenu.refreshMenuLayerPosition()).not.toThrow();
});
});
describe("getOwnedInlineMenuTagNames", () => {
it("returns tag names from the inline menu content service", () => {
inlineMenuContentService.getOwnedTagNames.mockReturnValue(["div", "span"]);
const result = autofillOverlayContentService.getOwnedInlineMenuTagNames();
expect(result).toEqual(["div", "span"]);
});
it("returns an empty array if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
const result = serviceWithoutInlineMenu.getOwnedInlineMenuTagNames();
expect(result).toEqual([]);
});
});
describe("getUnownedTopLayerItems", () => {
it("returns unowned top layer items from the inline menu content service", () => {
const mockElements = document.querySelectorAll("div");
inlineMenuContentService.getUnownedTopLayerItems.mockReturnValue(mockElements);
const result = autofillOverlayContentService.getUnownedTopLayerItems(true);
expect(result).toEqual(mockElements);
expect(inlineMenuContentService.getUnownedTopLayerItems).toHaveBeenCalledWith(true);
});
it("returns undefined if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
const result = serviceWithoutInlineMenu.getUnownedTopLayerItems();
expect(result).toBeUndefined();
});
});
describe("clearUserFilledFields", () => {
it("deletes all user filled fields", () => {
const mockElement1 = document.createElement("input") as FillableFormFieldElement;
const mockElement2 = document.createElement("input") as FillableFormFieldElement;
autofillOverlayContentService["userFilledFields"] = {
username: mockElement1,
password: mockElement2,
};
autofillOverlayContentService.clearUserFilledFields();
expect(autofillOverlayContentService["userFilledFields"]).toEqual({});
});
});
describe("handleOverlayRepositionEvent", () => { describe("handleOverlayRepositionEvent", () => {
const repositionEvents = [EVENTS.SCROLL, EVENTS.RESIZE]; const repositionEvents = [EVENTS.SCROLL, EVENTS.RESIZE];
repositionEvents.forEach((repositionEvent) => { repositionEvents.forEach((repositionEvent) => {
@@ -2049,7 +2128,7 @@ describe("AutofillOverlayContentService", () => {
}); });
it("skips focusing an element if no recently focused field exists", async () => { it("skips focusing an element if no recently focused field exists", async () => {
autofillOverlayContentService["mostRecentlyFocusedField"] = undefined; (autofillOverlayContentService as any)["mostRecentlyFocusedField"] = null;
sendMockExtensionMessage({ sendMockExtensionMessage({
command: "redirectAutofillInlineMenuFocusOut", command: "redirectAutofillInlineMenuFocusOut",
@@ -2149,7 +2228,6 @@ describe("AutofillOverlayContentService", () => {
}); });
it("returns null if the sub frame URL cannot be parsed correctly", async () => { it("returns null if the sub frame URL cannot be parsed correctly", async () => {
delete globalThis.location;
globalThis.location = { href: "invalid-base" } as Location; globalThis.location = { href: "invalid-base" } as Location;
sendMockExtensionMessage( sendMockExtensionMessage(
{ {

View File

@@ -945,7 +945,8 @@ export class InlineMenuFieldQualificationService
!fieldType || !fieldType ||
!this.usernameFieldTypes.has(fieldType) || !this.usernameFieldTypes.has(fieldType) ||
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) || this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) ||
this.fieldHasDisqualifyingAttributeValue(field) this.fieldHasDisqualifyingAttributeValue(field) ||
this.isTotpField(field)
) { ) {
return false; return false;
} }