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:
@@ -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;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export class AutoFillConstants {
|
|||||||
"otpcode",
|
"otpcode",
|
||||||
"onetimepassword",
|
"onetimepassword",
|
||||||
"security_code",
|
"security_code",
|
||||||
|
"second-factor",
|
||||||
"twofactor",
|
"twofactor",
|
||||||
"twofa",
|
"twofa",
|
||||||
"twofactorcode",
|
"twofactorcode",
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user