From bf60711efec33fd3b07227a58856dec6396e8e18 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Wed, 13 Dec 2023 10:25:16 -0600 Subject: [PATCH] [PM-934] Autofill not working until page has been refreshed (#6826) * [PM-934] Autofill not working until page has been refreshed * [PM-934] Adjusting cleanup of the messages_handler script * [PM-934] Fixing small issue found within collection of page details * [PM-934] Addressing concenrs brought up during code review * [PM-934] Addressing concenrs brought up during code review * [PM-934] Addressing concenrs brought up during code review * [PM-934] Addressing concenrs brought up during code review * [PM-934] Applying re-set changes to the autofill overlay implementation on reset of the extension * [PM-934] Applying jest tests to added logic within AutofillOverlayContent service * [PM-934] Fixing typo present in tabs background listener * [PM-934] Finishing up jest tests for updated implementation * [PM-934] Incorporating methodology for ensuring the autofill overlay updates to reflect user settings within existing tabs * [PM-934] Refining implementation to ensure we do not unnecessarily re-inject content scripts when the autofill overlay settings change * [PM-934] Working through jest tests for added implementation details * [PM-934] Working through jest tests for added implementation details * [PM-934] Finalizing jest tests for implemented logic * [PM-5035] Refactoring method structure --- .../autofill/background/overlay.background.ts | 2 +- .../autofill-service.factory.ts | 8 +- .../background/tabs.background.spec.ts | 14 +- .../autofill/background/tabs.background.ts | 18 ++- .../content/abstractions/autofill-init.ts | 3 + .../autofill/content/autofill-init.spec.ts | 58 ++++++- .../src/autofill/content/autofill-init.ts | 24 +++ .../src/autofill/content/autofiller.ts | 64 +++++--- .../content/bootstrap-autofill-overlay.ts | 3 + .../autofill/content/bootstrap-autofill.ts | 4 + .../src/autofill/content/message_handler.ts | 88 ++++++---- .../src/autofill/content/notification-bar.ts | 27 +++- .../src/autofill/enums/autofill-port.enums.ts | 5 + .../autofill-overlay-iframe.service.ts | 11 +- .../pages/button/autofill-overlay-button.ts | 2 +- .../pages/list/autofill-overlay-list.ts | 2 +- .../popup/settings/autofill.component.ts | 26 +++ .../autofill-overlay-content.service.ts | 2 + .../services/abstractions/autofill.service.ts | 8 +- .../collect-autofill-content.service.ts | 1 + .../autofill-overlay-content.service.spec.ts | 96 +++++++++++ .../autofill-overlay-content.service.ts | 38 ++++- .../services/autofill.service.spec.ts | 151 +++++++++++++++++- .../src/autofill/services/autofill.service.ts | 114 +++++++++++-- .../collect-autofill-content.service.ts | 11 ++ .../utils/{utils.spec.ts => index.spec.ts} | 113 ++++++++++++- .../src/autofill/utils/{utils.ts => index.ts} | 50 ++++++ .../browser/src/background/main.background.ts | 1 + .../src/background/runtime.background.ts | 11 +- apps/browser/src/manifest.json | 6 - apps/browser/test.setup.ts | 3 +- 31 files changed, 836 insertions(+), 128 deletions(-) create mode 100644 apps/browser/src/autofill/enums/autofill-port.enums.ts rename apps/browser/src/autofill/utils/{utils.spec.ts => index.spec.ts} (51%) rename apps/browser/src/autofill/utils/{utils.ts => index.ts} (65%) diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 3d6f00ec10..f760ee22e6 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -673,7 +673,7 @@ class OverlayBackground implements OverlayBackgroundInterface { */ private setupExtensionMessageListeners() { BrowserApi.messageListener("overlay.background", this.handleExtensionMessage); - chrome.runtime.onConnect.addListener(this.handlePortOnConnect); + BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect); } /** diff --git a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts index acd9be2a8e..972a2421cb 100644 --- a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts +++ b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts @@ -10,6 +10,10 @@ import { settingsServiceFactory, SettingsServiceInitOptions, } from "../../../background/service-factories/settings-service.factory"; +import { + configServiceFactory, + ConfigServiceInitOptions, +} from "../../../platform/background/service-factories/config-service.factory"; import { CachedServices, factory, @@ -43,7 +47,8 @@ export type AutoFillServiceInitOptions = AutoFillServiceOptions & EventCollectionServiceInitOptions & LogServiceInitOptions & SettingsServiceInitOptions & - UserVerificationServiceInitOptions; + UserVerificationServiceInitOptions & + ConfigServiceInitOptions; export function autofillServiceFactory( cache: { autofillService?: AbstractAutoFillService } & CachedServices, @@ -62,6 +67,7 @@ export function autofillServiceFactory( await logServiceFactory(cache, opts), await settingsServiceFactory(cache, opts), await userVerificationServiceFactory(cache, opts), + await configServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/autofill/background/tabs.background.spec.ts b/apps/browser/src/autofill/background/tabs.background.spec.ts index b3de1e96ce..304d43bd14 100644 --- a/apps/browser/src/autofill/background/tabs.background.spec.ts +++ b/apps/browser/src/autofill/background/tabs.background.spec.ts @@ -15,7 +15,7 @@ import OverlayBackground from "./overlay.background"; import TabsBackground from "./tabs.background"; describe("TabsBackground", () => { - let tabsBackgorund: TabsBackground; + let tabsBackground: TabsBackground; const mainBackground = mock({ messagingService: { send: jest.fn(), @@ -25,7 +25,7 @@ describe("TabsBackground", () => { const overlayBackground = mock(); beforeEach(() => { - tabsBackgorund = new TabsBackground(mainBackground, notificationBackground, overlayBackground); + tabsBackground = new TabsBackground(mainBackground, notificationBackground, overlayBackground); }); afterEach(() => { @@ -35,11 +35,11 @@ describe("TabsBackground", () => { describe("init", () => { it("sets up a window on focusChanged listener", () => { const handleWindowOnFocusChangedSpy = jest.spyOn( - tabsBackgorund as any, + tabsBackground as any, "handleWindowOnFocusChanged", ); - tabsBackgorund.init(); + tabsBackground.init(); expect(chrome.windows.onFocusChanged.addListener).toHaveBeenCalledWith( handleWindowOnFocusChangedSpy, @@ -49,7 +49,7 @@ describe("TabsBackground", () => { describe("tab event listeners", () => { beforeEach(() => { - tabsBackgorund.init(); + tabsBackground["setupTabEventListeners"](); }); describe("window onFocusChanged event", () => { @@ -64,7 +64,7 @@ describe("TabsBackground", () => { triggerWindowOnFocusedChangedEvent(10); await flushPromises(); - expect(tabsBackgorund["focusedWindowId"]).toBe(10); + expect(tabsBackground["focusedWindowId"]).toBe(10); }); it("updates the current tab data", async () => { @@ -144,7 +144,7 @@ describe("TabsBackground", () => { beforeEach(() => { mainBackground.onUpdatedRan = false; - tabsBackgorund["focusedWindowId"] = focusedWindowId; + tabsBackground["focusedWindowId"] = focusedWindowId; tab = mock({ windowId: focusedWindowId, active: true, diff --git a/apps/browser/src/autofill/background/tabs.background.ts b/apps/browser/src/autofill/background/tabs.background.ts index b095f99ce2..8b4cb356a7 100644 --- a/apps/browser/src/autofill/background/tabs.background.ts +++ b/apps/browser/src/autofill/background/tabs.background.ts @@ -20,6 +20,14 @@ export default class TabsBackground { return; } + this.updateCurrentTabData(); + this.setupTabEventListeners(); + } + + /** + * Sets up the tab and window event listeners. + */ + private setupTabEventListeners() { chrome.windows.onFocusChanged.addListener(this.handleWindowOnFocusChanged); chrome.tabs.onActivated.addListener(this.handleTabOnActivated); chrome.tabs.onReplaced.addListener(this.handleTabOnReplaced); @@ -33,7 +41,7 @@ export default class TabsBackground { * @param windowId - The ID of the window that was focused. */ private handleWindowOnFocusChanged = async (windowId: number) => { - if (!windowId) { + if (windowId == null || windowId < 0) { return; } @@ -116,8 +124,10 @@ export default class TabsBackground { * for the current tab. Also updates the overlay ciphers. */ private updateCurrentTabData = async () => { - await this.main.refreshBadge(); - await this.main.refreshMenu(); - await this.overlayBackground.updateOverlayCiphers(); + await Promise.all([ + this.main.refreshBadge(), + this.main.refreshMenu(), + this.overlayBackground.updateOverlayCiphers(), + ]); }; } diff --git a/apps/browser/src/autofill/content/abstractions/autofill-init.ts b/apps/browser/src/autofill/content/abstractions/autofill-init.ts index 139099a4d5..91866ffa0b 100644 --- a/apps/browser/src/autofill/content/abstractions/autofill-init.ts +++ b/apps/browser/src/autofill/content/abstractions/autofill-init.ts @@ -17,6 +17,7 @@ type AutofillExtensionMessage = { direction?: "previous" | "next"; isOpeningFullOverlay?: boolean; forceCloseOverlay?: boolean; + autofillOverlayVisibility?: number; }; }; @@ -34,10 +35,12 @@ type AutofillExtensionMessageHandlers = { updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void; bgUnlockPopoutOpened: () => void; bgVaultItemRepromptPopoutOpened: () => void; + updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void; }; interface AutofillInit { init(): void; + destroy(): void; } export { AutofillExtensionMessage, AutofillExtensionMessageHandlers, AutofillInit }; diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index 1524fdce10..ecf6774018 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -6,7 +6,7 @@ import { flushPromises, sendExtensionRuntimeMessage } from "../jest/testing-util import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; -import { RedirectFocusDirection } from "../utils/autofill-overlay.enum"; +import { AutofillOverlayVisibility, RedirectFocusDirection } from "../utils/autofill-overlay.enum"; import { AutofillExtensionMessage } from "./abstractions/autofill-init"; import AutofillInit from "./autofill-init"; @@ -16,6 +16,11 @@ describe("AutofillInit", () => { const autofillOverlayContentService = mock(); beforeEach(() => { + chrome.runtime.connect = jest.fn().mockReturnValue({ + onDisconnect: { + addListener: jest.fn(), + }, + }); autofillInit = new AutofillInit(autofillOverlayContentService); }); @@ -477,6 +482,57 @@ describe("AutofillInit", () => { expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); }); }); + + describe("updateAutofillOverlayVisibility", () => { + beforeEach(() => { + autofillInit["autofillOverlayContentService"].autofillOverlayVisibility = + AutofillOverlayVisibility.OnButtonClick; + }); + + it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => { + sendExtensionRuntimeMessage({ + command: "updateAutofillOverlayVisibility", + data: {}, + }); + + expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( + AutofillOverlayVisibility.OnButtonClick, + ); + }); + + it("updates the overlay visibility value", () => { + const message = { + command: "updateAutofillOverlayVisibility", + data: { + autofillOverlayVisibility: AutofillOverlayVisibility.Off, + }, + }; + + sendExtensionRuntimeMessage(message); + + expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( + message.data.autofillOverlayVisibility, + ); + }); + }); + }); + }); + + describe("destroy", () => { + it("removes the extension message listeners", () => { + autofillInit.destroy(); + + expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith( + autofillInit["handleExtensionMessage"], + ); + }); + + it("destroys the collectAutofillContentService", () => { + jest.spyOn(autofillInit["collectAutofillContentService"], "destroy"); + + autofillInit.destroy(); + + expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled(); }); }); }); diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index 9b23305377..5a2ec3dd39 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -26,6 +26,7 @@ class AutofillInit implements AutofillInitInterface { updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message), bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(), bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(), + updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message), }; /** @@ -214,6 +215,19 @@ class AutofillInit implements AutofillInitInterface { ); } + /** + * Updates the autofill overlay visibility. + * + * @param data - Contains the autoFillOverlayVisibility value + */ + private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) { + if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) { + return; + } + + this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility; + } + /** * Sets up the extension message listeners for the content script. */ @@ -247,6 +261,16 @@ class AutofillInit implements AutofillInitInterface { Promise.resolve(messageResponse).then((response) => sendResponse(response)); return true; }; + + /** + * Handles destroying the autofill init content script. Removes all + * listeners, timeouts, and object instances to prevent memory leaks. + */ + destroy() { + chrome.runtime.onMessage.removeListener(this.handleExtensionMessage); + this.collectAutofillContentService.destroy(); + this.autofillOverlayContentService?.destroy(); + } } export default AutofillInit; diff --git a/apps/browser/src/autofill/content/autofiller.ts b/apps/browser/src/autofill/content/autofiller.ts index 7f58e72c7d..c3a2f7f579 100644 --- a/apps/browser/src/autofill/content/autofiller.ts +++ b/apps/browser/src/autofill/content/autofiller.ts @@ -1,3 +1,5 @@ +import { getFromLocalStorage, setupExtensionDisconnectAction } from "../utils"; + if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", loadAutofiller); } else { @@ -8,27 +10,30 @@ function loadAutofiller() { let pageHref: string = null; let filledThisHref = false; let delayFillTimeout: number; - - const activeUserIdKey = "activeUserId"; - let activeUserId: string; - - chrome.storage.local.get(activeUserIdKey, (obj: any) => { - if (obj == null || obj[activeUserIdKey] == null) { - return; - } - activeUserId = obj[activeUserIdKey]; - }); - - chrome.storage.local.get(activeUserId, (obj: any) => { - if (obj?.[activeUserId]?.settings?.enableAutoFillOnPageLoad === true) { - setInterval(() => doFillIfNeeded(), 500); - } - }); - chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg.command === "fillForm" && pageHref === msg.url) { + let doFillInterval: NodeJS.Timeout; + const handleExtensionDisconnect = () => { + clearDoFillInterval(); + clearDelayFillTimeout(); + }; + const handleExtensionMessage = (message: any) => { + if (message.command === "fillForm" && pageHref === message.url) { filledThisHref = true; } - }); + }; + + setupExtensionEventListeners(); + triggerUserFillOnLoad(); + + async function triggerUserFillOnLoad() { + const activeUserIdKey = "activeUserId"; + const userKeyStorage = await getFromLocalStorage(activeUserIdKey); + const activeUserId = userKeyStorage[activeUserIdKey]; + const activeUserStorage = await getFromLocalStorage(activeUserId); + if (activeUserStorage?.[activeUserId]?.settings?.enableAutoFillOnPageLoad === true) { + clearDoFillInterval(); + doFillInterval = setInterval(() => doFillIfNeeded(), 500); + } + } function doFillIfNeeded(force = false) { if (force || pageHref !== window.location.href) { @@ -36,9 +41,7 @@ function loadAutofiller() { // Some websites are slow and rendering all page content. Try to fill again later // if we haven't already. filledThisHref = false; - if (delayFillTimeout != null) { - window.clearTimeout(delayFillTimeout); - } + clearDelayFillTimeout(); delayFillTimeout = window.setTimeout(() => { if (!filledThisHref) { doFillIfNeeded(true); @@ -55,4 +58,21 @@ function loadAutofiller() { chrome.runtime.sendMessage(msg); } } + + function clearDoFillInterval() { + if (doFillInterval) { + window.clearInterval(doFillInterval); + } + } + + function clearDelayFillTimeout() { + if (delayFillTimeout) { + window.clearTimeout(delayFillTimeout); + } + } + + function setupExtensionEventListeners() { + setupExtensionDisconnectAction(handleExtensionDisconnect); + chrome.runtime.onMessage.addListener(handleExtensionMessage); + } } diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts index 5bc9fb1718..ab21e367c2 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts @@ -1,4 +1,5 @@ import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; +import { setupAutofillInitDisconnectAction } from "../utils"; import AutofillInit from "./autofill-init"; @@ -6,6 +7,8 @@ import AutofillInit from "./autofill-init"; if (!windowContext.bitwardenAutofillInit) { const autofillOverlayContentService = new AutofillOverlayContentService(); windowContext.bitwardenAutofillInit = new AutofillInit(autofillOverlayContentService); + setupAutofillInitDisconnectAction(windowContext); + windowContext.bitwardenAutofillInit.init(); } })(window); diff --git a/apps/browser/src/autofill/content/bootstrap-autofill.ts b/apps/browser/src/autofill/content/bootstrap-autofill.ts index 3264c77ea0..f98d4bc1d7 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill.ts @@ -1,8 +1,12 @@ +import { setupAutofillInitDisconnectAction } from "../utils"; + import AutofillInit from "./autofill-init"; (function (windowContext) { if (!windowContext.bitwardenAutofillInit) { windowContext.bitwardenAutofillInit = new AutofillInit(); + setupAutofillInitDisconnectAction(windowContext); + windowContext.bitwardenAutofillInit.init(); } })(window); diff --git a/apps/browser/src/autofill/content/message_handler.ts b/apps/browser/src/autofill/content/message_handler.ts index 9bf48e3b17..7b52aeb355 100644 --- a/apps/browser/src/autofill/content/message_handler.ts +++ b/apps/browser/src/autofill/content/message_handler.ts @@ -1,31 +1,4 @@ -window.addEventListener( - "message", - (event) => { - if (event.source !== window) { - return; - } - - if (event.data.command && event.data.command === "authResult") { - chrome.runtime.sendMessage({ - command: event.data.command, - code: event.data.code, - state: event.data.state, - lastpass: event.data.lastpass, - referrer: event.source.location.hostname, - }); - } - - if (event.data.command && event.data.command === "webAuthnResult") { - chrome.runtime.sendMessage({ - command: event.data.command, - data: event.data.data, - remember: event.data.remember, - referrer: event.source.location.hostname, - }); - } - }, - false, -); +import { setupExtensionDisconnectAction } from "../utils"; const forwardCommands = [ "bgUnlockPopoutOpened", @@ -34,8 +7,59 @@ const forwardCommands = [ "addedCipher", ]; -chrome.runtime.onMessage.addListener((event) => { - if (forwardCommands.includes(event.command)) { - chrome.runtime.sendMessage(event); +/** + * Handles sending extension messages to the background + * script based on window messages from the page. + * + * @param event - Window message event + */ +const handleWindowMessage = (event: MessageEvent) => { + if (event.source !== window) { + return; } -}); + + if (event.data.command && event.data.command === "authResult") { + chrome.runtime.sendMessage({ + command: event.data.command, + code: event.data.code, + state: event.data.state, + lastpass: event.data.lastpass, + referrer: event.source.location.hostname, + }); + } + + if (event.data.command && event.data.command === "webAuthnResult") { + chrome.runtime.sendMessage({ + command: event.data.command, + data: event.data.data, + remember: event.data.remember, + referrer: event.source.location.hostname, + }); + } +}; + +/** + * Handles forwarding any commands that need to trigger + * an action from one service of the extension background + * to another. + * + * @param message - Message from the extension + */ +const handleExtensionMessage = (message: any) => { + if (forwardCommands.includes(message.command)) { + chrome.runtime.sendMessage(message); + } +}; + +/** + * Handles cleaning up any event listeners that were + * added to the window or extension. + */ +const handleExtensionDisconnect = () => { + window.removeEventListener("message", handleWindowMessage); + chrome.runtime.onMessage.removeListener(handleExtensionMessage); +}; + +window.addEventListener("message", handleWindowMessage, false); +chrome.runtime.onMessage.addListener(handleExtensionMessage); +setupExtensionDisconnectAction(handleExtensionDisconnect); diff --git a/apps/browser/src/autofill/content/notification-bar.ts b/apps/browser/src/autofill/content/notification-bar.ts index 92e8f59938..6c3f3561e5 100644 --- a/apps/browser/src/autofill/content/notification-bar.ts +++ b/apps/browser/src/autofill/content/notification-bar.ts @@ -4,6 +4,7 @@ import AddLoginRuntimeMessage from "../notification/models/add-login-runtime-mes import ChangePasswordRuntimeMessage from "../notification/models/change-password-runtime-message"; import { FormData } from "../services/abstractions/autofill.service"; import { GlobalSettings, UserSettings } from "../types"; +import { getFromLocalStorage, setupExtensionDisconnectAction } from "../utils"; interface HTMLElementWithFormOpId extends HTMLElement { formOpId: string; @@ -122,6 +123,8 @@ async function loadNotificationBar() { } } + setupExtensionDisconnectAction(handleExtensionDisconnection); + if (!showNotificationBar) { return; } @@ -999,11 +1002,23 @@ async function loadNotificationBar() { return theEl === document; } + function handleExtensionDisconnection(port: chrome.runtime.Port) { + closeBar(false); + clearTimeout(domObservationCollectTimeoutId); + clearTimeout(collectPageDetailsTimeoutId); + clearTimeout(handlePageChangeTimeoutId); + observer?.disconnect(); + observer = null; + watchedForms.forEach((wf: WatchedForm) => { + const form = wf.formEl; + form.removeEventListener("submit", formSubmitted, false); + const submitButton = getSubmitButton( + form, + unionSets(logInButtonNames, changePasswordButtonNames), + ); + submitButton?.removeEventListener("click", formSubmitted, false); + }); + } + // End Helper Functions } - -async function getFromLocalStorage(keys: string | string[]): Promise> { - return new Promise((resolve) => { - chrome.storage.local.get(keys, (storage: Record) => resolve(storage)); - }); -} diff --git a/apps/browser/src/autofill/enums/autofill-port.enums.ts b/apps/browser/src/autofill/enums/autofill-port.enums.ts new file mode 100644 index 0000000000..e5b8f17aad --- /dev/null +++ b/apps/browser/src/autofill/enums/autofill-port.enums.ts @@ -0,0 +1,5 @@ +const AutofillPort = { + InjectedScript: "autofill-injected-script-port", +} as const; + +export { AutofillPort }; diff --git a/apps/browser/src/autofill/overlay/iframe-content/autofill-overlay-iframe.service.ts b/apps/browser/src/autofill/overlay/iframe-content/autofill-overlay-iframe.service.ts index 20f5aa830f..c878f961f1 100644 --- a/apps/browser/src/autofill/overlay/iframe-content/autofill-overlay-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/iframe-content/autofill-overlay-iframe.service.ts @@ -1,5 +1,5 @@ import { EVENTS } from "../../constants"; -import { setElementStyles } from "../../utils/utils"; +import { setElementStyles } from "../../utils"; import { BackgroundPortMessageHandlers, AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface, @@ -166,9 +166,10 @@ class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterf this.updateElementStyles(this.iframe, { opacity: "0", height: "0px", display: "block" }); globalThis.removeEventListener("message", this.handleWindowMessage); - this.port.onMessage.removeListener(this.handlePortMessage); - this.port.onDisconnect.removeListener(this.handlePortDisconnect); - this.port.disconnect(); + this.unobserveIframe(); + this.port?.onMessage.removeListener(this.handlePortMessage); + this.port?.onDisconnect.removeListener(this.handlePortDisconnect); + this.port?.disconnect(); this.port = null; }; @@ -369,7 +370,7 @@ class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterf * Unobserves the iframe element for mutations to its style attribute. */ private unobserveIframe() { - this.iframeMutationObserver.disconnect(); + this.iframeMutationObserver?.disconnect(); } /** diff --git a/apps/browser/src/autofill/overlay/pages/button/autofill-overlay-button.ts b/apps/browser/src/autofill/overlay/pages/button/autofill-overlay-button.ts index 94c0772fd2..bfb5708745 100644 --- a/apps/browser/src/autofill/overlay/pages/button/autofill-overlay-button.ts +++ b/apps/browser/src/autofill/overlay/pages/button/autofill-overlay-button.ts @@ -3,8 +3,8 @@ import "lit/polyfill-support.js"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EVENTS } from "../../../constants"; +import { buildSvgDomElement } from "../../../utils"; import { logoIcon, logoLockedIcon } from "../../../utils/svg-icons"; -import { buildSvgDomElement } from "../../../utils/utils"; import { InitAutofillOverlayButtonMessage, OverlayButtonWindowMessageHandlers, diff --git a/apps/browser/src/autofill/overlay/pages/list/autofill-overlay-list.ts b/apps/browser/src/autofill/overlay/pages/list/autofill-overlay-list.ts index 053fddb9c1..3f13061a0e 100644 --- a/apps/browser/src/autofill/overlay/pages/list/autofill-overlay-list.ts +++ b/apps/browser/src/autofill/overlay/pages/list/autofill-overlay-list.ts @@ -4,8 +4,8 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { OverlayCipherData } from "../../../background/abstractions/overlay.background"; import { EVENTS } from "../../../constants"; +import { buildSvgDomElement } from "../../../utils"; import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../utils/svg-icons"; -import { buildSvgDomElement } from "../../../utils/utils"; import { InitAutofillOverlayListMessage, OverlayListWindowMessageHandlers, diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index d9038b0eb2..728b74bc90 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -10,6 +10,7 @@ import { UriMatchType } from "@bitwarden/common/vault/enums"; import { BrowserApi } from "../../../platform/browser/browser-api"; import { flagEnabled } from "../../../platform/flags"; +import { AutofillService } from "../../services/abstractions/autofill.service"; import { AutofillOverlayVisibility } from "../../utils/autofill-overlay.enum"; @Component({ @@ -35,6 +36,7 @@ export class AutofillComponent implements OnInit { private platformUtilsService: PlatformUtilsService, private configService: ConfigServiceAbstraction, private settingsService: SettingsService, + private autofillService: AutofillService, ) { this.autoFillOverlayVisibilityOptions = [ { @@ -86,7 +88,10 @@ export class AutofillComponent implements OnInit { } async updateAutoFillOverlayVisibility() { + const previousAutoFillOverlayVisibility = + await this.settingsService.getAutoFillOverlayVisibility(); await this.settingsService.setAutoFillOverlayVisibility(this.autoFillOverlayVisibility); + await this.handleUpdatingAutofillOverlayContentScripts(previousAutoFillOverlayVisibility); } async updateAutoFillOnPageLoad() { @@ -144,4 +149,25 @@ export class AutofillComponent implements OnInit { event.preventDefault(); BrowserApi.createNewTab(this.disablePasswordManagerLink); } + + private async handleUpdatingAutofillOverlayContentScripts( + previousAutoFillOverlayVisibility: number, + ) { + const autofillOverlayPreviouslyDisabled = + previousAutoFillOverlayVisibility === AutofillOverlayVisibility.Off; + const autofillOverlayCurrentlyDisabled = + this.autoFillOverlayVisibility === AutofillOverlayVisibility.Off; + + if (!autofillOverlayPreviouslyDisabled && !autofillOverlayCurrentlyDisabled) { + const tabs = await BrowserApi.tabsQuery({}); + tabs.forEach((tab) => + BrowserApi.tabSendMessageData(tab, "updateAutofillOverlayVisibility", { + autofillOverlayVisibility: this.autoFillOverlayVisibility, + }), + ); + return; + } + + await this.autofillService.reloadAutofillScripts(); + } } diff --git a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts index ac7d55a54d..ec594ac829 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts @@ -14,6 +14,7 @@ interface AutofillOverlayContentService { isCurrentlyFilling: boolean; isOverlayCiphersPopulated: boolean; pageDetailsUpdateRequired: boolean; + autofillOverlayVisibility: number; init(): void; setupAutofillOverlayListenerOnField( autofillFieldElement: ElementWithOpId, @@ -27,6 +28,7 @@ interface AutofillOverlayContentService { redirectOverlayFocusOut(direction: "previous" | "next"): void; focusMostRecentOverlayField(): void; blurMostRecentOverlayField(): void; + destroy(): void; } export { OpenAutofillOverlayOptions, AutofillOverlayContentService }; diff --git a/apps/browser/src/autofill/services/abstractions/autofill.service.ts b/apps/browser/src/autofill/services/abstractions/autofill.service.ts index a0959db72c..c44e3adf7c 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill.service.ts @@ -44,10 +44,12 @@ export interface GenerateFillScriptOptions { } export abstract class AutofillService { + loadAutofillScriptsOnInstall: () => Promise; + reloadAutofillScripts: () => Promise; injectAutofillScripts: ( - sender: chrome.runtime.MessageSender, - autofillV2?: boolean, - autofillOverlay?: boolean, + tab: chrome.tabs.Tab, + frameId?: number, + triggeringOnPageLoad?: boolean, ) => Promise; getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[]; doAutoFill: (options: AutoFillOptions) => Promise; diff --git a/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts index 78befa7bc6..46ad615059 100644 --- a/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts @@ -22,6 +22,7 @@ interface CollectAutofillContentService { filterCallback: CallableFunction, isObservingShadowRoot?: boolean, ): Node[]; + destroy(): void; } export { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 7753a4b267..f3aa77258f 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -1609,4 +1609,100 @@ describe("AutofillOverlayContentService", () => { expect(autofillOverlayContentService["removeAutofillOverlay"]).toHaveBeenCalled(); }); }); + + describe("destroy", () => { + let autofillFieldElement: ElementWithOpId; + let autofillFieldData: AutofillField; + + beforeEach(() => { + document.body.innerHTML = ` +
+ + +
+ `; + + autofillFieldElement = document.getElementById( + "username-field", + ) as ElementWithOpId; + autofillFieldElement.opid = "op-1"; + autofillFieldData = createAutofillFieldMock({ + opid: "username-field", + form: "validFormId", + placeholder: "username", + elementNumber: 1, + }); + autofillOverlayContentService.setupAutofillOverlayListenerOnField( + autofillFieldElement, + autofillFieldData, + ); + autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; + }); + + it("disconnects all mutation observers", () => { + autofillOverlayContentService["setupMutationObserver"](); + jest.spyOn(autofillOverlayContentService["bodyElementMutationObserver"], "disconnect"); + jest.spyOn(autofillOverlayContentService["documentElementMutationObserver"], "disconnect"); + + autofillOverlayContentService.destroy(); + + expect( + autofillOverlayContentService["documentElementMutationObserver"].disconnect, + ).toHaveBeenCalled(); + expect( + autofillOverlayContentService["bodyElementMutationObserver"].disconnect, + ).toHaveBeenCalled(); + }); + + it("clears the user interaction event timeout", () => { + jest.spyOn(autofillOverlayContentService as any, "clearUserInteractionEventTimeout"); + + autofillOverlayContentService.destroy(); + + expect(autofillOverlayContentService["clearUserInteractionEventTimeout"]).toHaveBeenCalled(); + }); + + it("de-registers all global event listeners", () => { + jest.spyOn(globalThis.document, "removeEventListener"); + jest.spyOn(globalThis, "removeEventListener"); + jest.spyOn(autofillOverlayContentService as any, "removeOverlayRepositionEventListeners"); + + autofillOverlayContentService.destroy(); + + expect(globalThis.document.removeEventListener).toHaveBeenCalledWith( + EVENTS.VISIBILITYCHANGE, + autofillOverlayContentService["handleVisibilityChangeEvent"], + ); + expect(globalThis.removeEventListener).toHaveBeenCalledWith( + EVENTS.FOCUSOUT, + autofillOverlayContentService["handleFormFieldBlurEvent"], + ); + expect( + autofillOverlayContentService["removeOverlayRepositionEventListeners"], + ).toHaveBeenCalled(); + }); + + it("de-registers any event listeners that are attached to the form field elements", () => { + jest.spyOn(autofillOverlayContentService as any, "removeCachedFormFieldEventListeners"); + jest.spyOn(autofillFieldElement, "removeEventListener"); + jest.spyOn(autofillOverlayContentService["formFieldElements"], "delete"); + + autofillOverlayContentService.destroy(); + + expect( + autofillOverlayContentService["removeCachedFormFieldEventListeners"], + ).toHaveBeenCalledWith(autofillFieldElement); + expect(autofillFieldElement.removeEventListener).toHaveBeenCalledWith( + EVENTS.BLUR, + autofillOverlayContentService["handleFormFieldBlurEvent"], + ); + expect(autofillFieldElement.removeEventListener).toHaveBeenCalledWith( + EVENTS.KEYUP, + autofillOverlayContentService["handleFormFieldKeyupEvent"], + ); + expect(autofillOverlayContentService["formFieldElements"].delete).toHaveBeenCalledWith( + autofillFieldElement, + ); + }); + }); }); diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 9e5acae887..c713c6ea41 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -10,16 +10,12 @@ import AutofillField from "../models/autofill-field"; import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe"; import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe"; import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; +import { generateRandomCustomElementName, sendExtensionMessage, setElementStyles } from "../utils"; import { AutofillOverlayElement, RedirectFocusDirection, AutofillOverlayVisibility, } from "../utils/autofill-overlay.enum"; -import { - generateRandomCustomElementName, - sendExtensionMessage, - setElementStyles, -} from "../utils/utils"; import { AutofillOverlayContentService as AutofillOverlayContentServiceInterface, @@ -32,9 +28,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte isCurrentlyFilling = false; isOverlayCiphersPopulated = false; pageDetailsUpdateRequired = false; + autofillOverlayVisibility: number; private readonly findTabs = tabbable; private readonly sendExtensionMessage = sendExtensionMessage; - private autofillOverlayVisibility: number; + private formFieldElements: Set> = new Set([]); private userFilledFields: Record = {}; private authStatus: AuthenticationStatus; private focusableElements: FocusableElement[] = []; @@ -47,6 +44,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte private userInteractionEventTimeout: NodeJS.Timeout; private overlayElementsMutationObserver: MutationObserver; private bodyElementMutationObserver: MutationObserver; + private documentElementMutationObserver: MutationObserver; private mutationObserverIterations = 0; private mutationObserverIterationsResetTimeout: NodeJS.Timeout; private autofillFieldKeywordsMap: WeakMap = new WeakMap(); @@ -86,6 +84,8 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte return; } + this.formFieldElements.add(formFieldElement); + if (!this.autofillOverlayVisibility) { await this.getAutofillOverlayVisibility(); } @@ -901,10 +901,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte this.handleBodyElementMutationObserverUpdate, ); - const documentElementMutationObserver = new MutationObserver( + this.documentElementMutationObserver = new MutationObserver( this.handleDocumentElementMutationObserverUpdate, ); - documentElementMutationObserver.observe(globalThis.document.documentElement, { + this.documentElementMutationObserver.observe(globalThis.document.documentElement, { childList: true, }); }; @@ -1117,6 +1117,28 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte const documentRoot = element.getRootNode() as ShadowRoot | Document; return documentRoot?.activeElement; } + + /** + * Destroys the autofill overlay content service. This method will + * disconnect the mutation observers and remove all event listeners. + */ + destroy() { + this.documentElementMutationObserver?.disconnect(); + this.clearUserInteractionEventTimeout(); + this.formFieldElements.forEach((formFieldElement) => { + this.removeCachedFormFieldEventListeners(formFieldElement); + formFieldElement.removeEventListener(EVENTS.BLUR, this.handleFormFieldBlurEvent); + formFieldElement.removeEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent); + this.formFieldElements.delete(formFieldElement); + }); + globalThis.document.removeEventListener( + EVENTS.VISIBILITYCHANGE, + this.handleVisibilityChangeEvent, + ); + globalThis.removeEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent); + this.removeAutofillOverlay(); + this.removeOverlayRepositionEventListeners(); + } } export default AutofillOverlayContentService; diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index aa9232c791..5f9c1db68c 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -2,7 +2,9 @@ import { mock, mockReset } from "jest-mock-extended"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { SettingsService } from "@bitwarden/common/services/settings.service"; import { @@ -24,6 +26,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserStateService } from "../../platform/services/browser-state.service"; +import { AutofillPort } from "../enums/autofill-port.enums"; import { createAutofillFieldMock, createAutofillPageDetailsMock, @@ -54,6 +57,7 @@ describe("AutofillService", () => { const logService = mock(); const settingsService = mock(); const userVerificationService = mock(); + const configService = mock(); beforeEach(() => { autofillService = new AutofillService( @@ -64,6 +68,7 @@ describe("AutofillService", () => { logService, settingsService, userVerificationService, + configService, ); }); @@ -72,6 +77,72 @@ describe("AutofillService", () => { mockReset(cipherService); }); + describe("loadAutofillScriptsOnInstall", () => { + let tab1: chrome.tabs.Tab; + let tab2: chrome.tabs.Tab; + let tab3: chrome.tabs.Tab; + + beforeEach(() => { + tab1 = createChromeTabMock({ id: 1, url: "https://some-url.com" }); + tab2 = createChromeTabMock({ id: 2, url: "http://some-url.com" }); + tab3 = createChromeTabMock({ id: 3, url: "chrome-extension://some-extension-route" }); + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([tab1, tab2]); + }); + + it("queries all browser tabs and injects the autofill scripts into them", async () => { + jest.spyOn(autofillService, "injectAutofillScripts"); + + await autofillService.loadAutofillScriptsOnInstall(); + + expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({}); + expect(autofillService.injectAutofillScripts).toHaveBeenCalledWith(tab1, 0, false); + expect(autofillService.injectAutofillScripts).toHaveBeenCalledWith(tab2, 0, false); + }); + + it("skips injecting scripts into tabs that do not have an http(s) protocol", async () => { + jest.spyOn(autofillService, "injectAutofillScripts"); + + await autofillService.loadAutofillScriptsOnInstall(); + + expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({}); + expect(autofillService.injectAutofillScripts).not.toHaveBeenCalledWith(tab3); + }); + + it("sets up an extension runtime onConnect listener", async () => { + await autofillService.loadAutofillScriptsOnInstall(); + + // eslint-disable-next-line no-restricted-syntax + expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + describe("reloadAutofillScripts", () => { + it("disconnects and removes all autofill script ports", () => { + const port1 = mock({ + disconnect: jest.fn(), + }); + const port2 = mock({ + disconnect: jest.fn(), + }); + autofillService["autofillScriptPortsSet"] = new Set([port1, port2]); + + autofillService.reloadAutofillScripts(); + + expect(port1.disconnect).toHaveBeenCalled(); + expect(port2.disconnect).toHaveBeenCalled(); + expect(autofillService["autofillScriptPortsSet"].size).toBe(0); + }); + + it("re-injects the autofill scripts in all tabs", () => { + autofillService["autofillScriptPortsSet"] = new Set([mock()]); + jest.spyOn(autofillService as any, "injectAutofillScriptsInAllTabs"); + + autofillService.reloadAutofillScripts(); + + expect(autofillService["injectAutofillScriptsInAllTabs"]).toHaveBeenCalled(); + }); + }); + describe("injectAutofillScripts", () => { const autofillV1Script = "autofill.js"; const autofillV2BootstrapScript = "bootstrap-autofill.js"; @@ -83,12 +154,12 @@ describe("AutofillService", () => { beforeEach(() => { tabMock = createChromeTabMock(); - sender = { tab: tabMock }; + sender = { tab: tabMock, frameId: 1 }; jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation(); }); it("accepts an extension message sender and injects the autofill scripts into the tab of the sender", async () => { - await autofillService.injectAutofillScripts(sender); + await autofillService.injectAutofillScripts(sender.tab, sender.frameId, true); [autofillV1Script, ...defaultAutofillScripts].forEach((scriptName) => { expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { @@ -105,7 +176,11 @@ describe("AutofillService", () => { }); it("will inject the bootstrap-autofill script if the enableAutofillV2 flag is set", async () => { - await autofillService.injectAutofillScripts(sender, true); + jest + .spyOn(configService, "getFeatureFlag") + .mockImplementation((flag) => Promise.resolve(flag === FeatureFlag.AutofillV2)); + + await autofillService.injectAutofillScripts(sender.tab, sender.frameId); expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { file: `content/${autofillV2BootstrapScript}`, @@ -120,11 +195,16 @@ describe("AutofillService", () => { }); it("will inject the bootstrap-autofill-overlay script if the enableAutofillOverlay flag is set and the user has the autofill overlay enabled", async () => { + jest + .spyOn(configService, "getFeatureFlag") + .mockImplementation((flag) => + Promise.resolve(flag === FeatureFlag.AutofillOverlay || flag === FeatureFlag.AutofillV2), + ); jest .spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility") .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); - await autofillService.injectAutofillScripts(sender, true, true); + await autofillService.injectAutofillScripts(sender.tab, sender.frameId); expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { file: `content/${autofillOverlayBootstrapScript}`, @@ -144,18 +224,25 @@ describe("AutofillService", () => { }); it("will inject the bootstrap-autofill script if the enableAutofillOverlay flag is set but the user does not have the autofill overlay enabled", async () => { + jest + .spyOn(configService, "getFeatureFlag") + .mockImplementation((flag) => + Promise.resolve(flag === FeatureFlag.AutofillOverlay || flag === FeatureFlag.AutofillV2), + ); jest .spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility") .mockResolvedValue(AutofillOverlayVisibility.Off); - await autofillService.injectAutofillScripts(sender, true, true); + await autofillService.injectAutofillScripts(sender.tab, sender.frameId); expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { file: `content/${autofillV2BootstrapScript}`, + frameId: sender.frameId, ...defaultExecuteScriptOptions, }); expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, { file: `content/${autofillV1Script}`, + frameId: sender.frameId, ...defaultExecuteScriptOptions, }); }); @@ -4436,4 +4523,58 @@ describe("AutofillService", () => { expect(autofillService["currentlyOpeningPasswordRepromptPopout"]).toBe(false); }); }); + + describe("handleInjectedScriptPortConnection", () => { + it("ignores port connections that do not have the correct port name", () => { + const port = mock({ + name: "some-invalid-port-name", + onDisconnect: { addListener: jest.fn() }, + }) as any; + + autofillService["handleInjectedScriptPortConnection"](port); + + expect(port.onDisconnect.addListener).not.toHaveBeenCalled(); + expect(autofillService["autofillScriptPortsSet"].size).toBe(0); + }); + + it("adds the connect port to the set of injected script ports and sets up an onDisconnect listener", () => { + const port = mock({ + name: AutofillPort.InjectedScript, + onDisconnect: { addListener: jest.fn() }, + }) as any; + jest.spyOn(autofillService as any, "handleInjectScriptPortOnDisconnect"); + + autofillService["handleInjectedScriptPortConnection"](port); + + expect(port.onDisconnect.addListener).toHaveBeenCalledWith( + autofillService["handleInjectScriptPortOnDisconnect"], + ); + expect(autofillService["autofillScriptPortsSet"].size).toBe(1); + }); + }); + + describe("handleInjectScriptPortOnDisconnect", () => { + it("ignores port disconnections that do not have the correct port name", () => { + autofillService["autofillScriptPortsSet"].add(mock()); + + autofillService["handleInjectScriptPortOnDisconnect"]( + mock({ + name: "some-invalid-port-name", + }), + ); + + expect(autofillService["autofillScriptPortsSet"].size).toBe(1); + }); + + it("removes the port from the set of injected script ports", () => { + const port = mock({ + name: AutofillPort.InjectedScript, + }) as any; + autofillService["autofillScriptPortsSet"].add(port); + + autofillService["handleInjectScriptPortOnDisconnect"](port); + + expect(autofillService["autofillScriptPortsSet"].size).toBe(0); + }); + }); }); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index a1ef5a47a1..eddc8f93a9 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -2,6 +2,8 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; @@ -13,6 +15,7 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window"; +import { AutofillPort } from "../enums/autofill-port.enums"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; @@ -35,6 +38,7 @@ export default class AutofillService implements AutofillServiceInterface { private openVaultItemPasswordRepromptPopout = openVaultItemPasswordRepromptPopout; private openPasswordRepromptPopoutDebounce: NodeJS.Timeout; private currentlyOpeningPasswordRepromptPopout = false; + private autofillScriptPortsSet = new Set(); constructor( private cipherService: CipherService, @@ -44,23 +48,54 @@ export default class AutofillService implements AutofillServiceInterface { private logService: LogService, private settingsService: SettingsService, private userVerificationService: UserVerificationService, + private configService: ConfigServiceAbstraction, ) {} + /** + * Triggers on installation of the extension Handles injecting + * content scripts into all tabs that are currently open, and + * sets up a listener to ensure content scripts can identify + * if the extension context has been disconnected. + */ + async loadAutofillScriptsOnInstall() { + BrowserApi.addListener(chrome.runtime.onConnect, this.handleInjectedScriptPortConnection); + + this.injectAutofillScriptsInAllTabs(); + } + + /** + * Triggers a complete reload of all autofill scripts on tabs open within + * the user's browsing session. This is done by first disconnecting all + * existing autofill content script ports, which cleans up existing object + * instances, and then re-injecting the autofill scripts into all tabs. + */ + async reloadAutofillScripts() { + this.autofillScriptPortsSet.forEach((port) => { + port.disconnect(); + this.autofillScriptPortsSet.delete(port); + }); + + this.injectAutofillScriptsInAllTabs(); + } + /** * Injects the autofill scripts into the current tab and all frames * found within the tab. Temporarily, will conditionally inject * the refactor of the core autofill script if the feature flag * is enabled. - * @param {chrome.runtime.MessageSender} sender - * @param {boolean} autofillV2 - * @param {boolean} autofillOverlay - * @returns {Promise} + * @param {chrome.tabs.Tab} tab + * @param {number} frameId + * @param {boolean} triggeringOnPageLoad */ async injectAutofillScripts( - sender: chrome.runtime.MessageSender, - autofillV2 = false, - autofillOverlay = false, - ) { + tab: chrome.tabs.Tab, + frameId = 0, + triggeringOnPageLoad = true, + ): Promise { + const autofillV2 = await this.configService.getFeatureFlag(FeatureFlag.AutofillV2); + const autofillOverlay = await this.configService.getFeatureFlag( + FeatureFlag.AutofillOverlay, + ); let mainAutofillScript = "autofill.js"; const isUsingAutofillOverlay = @@ -73,20 +108,24 @@ export default class AutofillService implements AutofillServiceInterface { : "bootstrap-autofill.js"; } - const injectedScripts = [ - mainAutofillScript, - "autofiller.js", - "notificationBar.js", - "contextMenuHandler.js", - ]; + const injectedScripts = [mainAutofillScript]; + if (triggeringOnPageLoad) { + injectedScripts.push("autofiller.js"); + } + injectedScripts.push("notificationBar.js", "contextMenuHandler.js"); for (const injectedScript of injectedScripts) { - await BrowserApi.executeScriptInTab(sender.tab.id, { + await BrowserApi.executeScriptInTab(tab.id, { file: `content/${injectedScript}`, - frameId: sender.frameId, + frameId, runAt: "document_start", }); } + + await BrowserApi.executeScriptInTab(tab.id, { + file: "content/message_handler.js", + runAt: "document_start", + }); } /** @@ -1877,4 +1916,47 @@ export default class AutofillService implements AutofillServiceInterface { return false; } + + /** + * Handles incoming long-lived connections from injected autofill scripts. + * Stores the port in a set to facilitate disconnecting ports if the extension + * needs to re-inject the autofill scripts. + * + * @param port - The port that was connected + */ + private handleInjectedScriptPortConnection = (port: chrome.runtime.Port) => { + if (port.name !== AutofillPort.InjectedScript) { + return; + } + + this.autofillScriptPortsSet.add(port); + port.onDisconnect.addListener(this.handleInjectScriptPortOnDisconnect); + }; + + /** + * Handles disconnecting ports that relate to injected autofill scripts. + + * @param port - The port that was disconnected + */ + private handleInjectScriptPortOnDisconnect = (port: chrome.runtime.Port) => { + if (port.name !== AutofillPort.InjectedScript) { + return; + } + + this.autofillScriptPortsSet.delete(port); + }; + + /** + * Queries all open tabs in the user's browsing session + * and injects the autofill scripts into the page. + */ + private async injectAutofillScriptsInAllTabs() { + const tabs = await BrowserApi.tabsQuery({}); + for (let index = 0; index < tabs.length; index++) { + const tab = tabs[index]; + if (tab.url?.startsWith("http")) { + this.injectAutofillScripts(tab, 0, false); + } + } + } } diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index d675a37921..ebddc20141 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1249,6 +1249,17 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return attributeValue; } + + /** + * Destroys the CollectAutofillContentService. Clears all + * timeouts and disconnects the mutation observer. + */ + destroy() { + if (this.updateAutofillElementsAfterMutationTimeout) { + clearTimeout(this.updateAutofillElementsAfterMutationTimeout); + } + this.mutationObserver?.disconnect(); + } } export default CollectAutofillContentService; diff --git a/apps/browser/src/autofill/utils/utils.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts similarity index 51% rename from apps/browser/src/autofill/utils/utils.spec.ts rename to apps/browser/src/autofill/utils/index.spec.ts index 1da83fef24..4024d5839a 100644 --- a/apps/browser/src/autofill/utils/utils.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -1,10 +1,17 @@ +import { AutofillPort } from "../enums/autofill-port.enums"; +import { triggerPortOnDisconnectEvent } from "../jest/testing-utils"; + import { logoIcon, logoLockedIcon } from "./svg-icons"; + import { buildSvgDomElement, generateRandomCustomElementName, sendExtensionMessage, setElementStyles, -} from "./utils"; + getFromLocalStorage, + setupExtensionDisconnectAction, + setupAutofillInitDisconnectAction, +} from "./index"; describe("buildSvgDomElement", () => { it("returns an SVG DOM element", () => { @@ -116,3 +123,107 @@ describe("setElementStyles", () => { expect(testDiv.style.cssText).toEqual(expectedCSSRuleString); }); }); + +describe("getFromLocalStorage", () => { + it("returns a promise with the storage object pulled from the extension storage api", async () => { + const localStorage: Record = { + testValue: "test", + another: "another", + }; + jest.spyOn(chrome.storage.local, "get").mockImplementation((keys, callback) => { + const localStorageObject: Record = {}; + + if (typeof keys === "string") { + localStorageObject[keys] = localStorage[keys]; + } else if (Array.isArray(keys)) { + for (const key of keys) { + localStorageObject[key] = localStorage[key]; + } + } + + callback(localStorageObject); + }); + + const returnValue = await getFromLocalStorage("testValue"); + + expect(chrome.storage.local.get).toHaveBeenCalled(); + expect(returnValue).toEqual({ testValue: "test" }); + }); +}); + +describe("setupExtensionDisconnectAction", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("connects a port to the extension background and sets up an onDisconnect listener", () => { + const onDisconnectCallback = jest.fn(); + let port: chrome.runtime.Port; + jest.spyOn(chrome.runtime, "connect").mockImplementation(() => { + port = { + onDisconnect: { + addListener: onDisconnectCallback, + removeListener: jest.fn(), + }, + } as unknown as chrome.runtime.Port; + + return port; + }); + + setupExtensionDisconnectAction(onDisconnectCallback); + + expect(chrome.runtime.connect).toHaveBeenCalledWith({ + name: AutofillPort.InjectedScript, + }); + expect(port.onDisconnect.addListener).toHaveBeenCalledWith(expect.any(Function)); + }); +}); + +describe("setupAutofillInitDisconnectAction", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("skips setting up the extension disconnect action if the bitwardenAutofillInit object is not populated", () => { + const onDisconnectCallback = jest.fn(); + window.bitwardenAutofillInit = undefined; + const portConnectSpy = jest.spyOn(chrome.runtime, "connect").mockImplementation(() => { + return { + onDisconnect: { + addListener: onDisconnectCallback, + removeListener: jest.fn(), + }, + } as unknown as chrome.runtime.Port; + }); + + setupAutofillInitDisconnectAction(window); + + expect(portConnectSpy).not.toHaveBeenCalled(); + }); + + it("destroys the autofill init instance when the port is disconnected", () => { + let port: chrome.runtime.Port; + const autofillInitDestroy: CallableFunction = jest.fn(); + window.bitwardenAutofillInit = { + destroy: autofillInitDestroy, + } as any; + jest.spyOn(chrome.runtime, "connect").mockImplementation(() => { + port = { + onDisconnect: { + addListener: jest.fn(), + removeListener: jest.fn(), + }, + } as unknown as chrome.runtime.Port; + + return port; + }); + + setupAutofillInitDisconnectAction(window); + triggerPortOnDisconnectEvent(port as chrome.runtime.Port); + + expect(chrome.runtime.connect).toHaveBeenCalled(); + expect(port.onDisconnect.addListener).toHaveBeenCalled(); + expect(autofillInitDestroy).toHaveBeenCalled(); + expect(window.bitwardenAutofillInit).toBeUndefined(); + }); +}); diff --git a/apps/browser/src/autofill/utils/utils.ts b/apps/browser/src/autofill/utils/index.ts similarity index 65% rename from apps/browser/src/autofill/utils/utils.ts rename to apps/browser/src/autofill/utils/index.ts index 73e133da32..a2ce51c8cc 100644 --- a/apps/browser/src/autofill/utils/utils.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -1,3 +1,5 @@ +import { AutofillPort } from "../enums/autofill-port.enums"; + /** * Generates a random string of characters that formatted as a custom element name. */ @@ -103,9 +105,57 @@ function setElementStyles( } } +/** + * Get data from local storage based on the keys provided. + * + * @param keys - String or array of strings of keys to get from local storage + */ +async function getFromLocalStorage(keys: string | string[]): Promise> { + return new Promise((resolve) => { + chrome.storage.local.get(keys, (storage: Record) => resolve(storage)); + }); +} + +/** + * Sets up a long-lived connection with the extension background + * and triggers an onDisconnect event if the extension context + * is invalidated. + * + * @param callback - Callback function to run when the extension disconnects + */ +function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => void) { + const port = chrome.runtime.connect({ name: AutofillPort.InjectedScript }); + const onDisconnectCallback = (disconnectedPort: chrome.runtime.Port) => { + callback(disconnectedPort); + port.onDisconnect.removeListener(onDisconnectCallback); + }; + port.onDisconnect.addListener(onDisconnectCallback); +} + +/** + * Handles setup of the extension disconnect action for the autofill init class + * in both instances where the overlay might or might not be initialized. + * + * @param windowContext - The global window context + */ +function setupAutofillInitDisconnectAction(windowContext: Window) { + if (!windowContext.bitwardenAutofillInit) { + return; + } + + const onDisconnectCallback = () => { + windowContext.bitwardenAutofillInit.destroy(); + delete windowContext.bitwardenAutofillInit; + }; + setupExtensionDisconnectAction(onDisconnectCallback); +} + export { generateRandomCustomElementName, buildSvgDomElement, sendExtensionMessage, setElementStyles, + getFromLocalStorage, + setupExtensionDisconnectAction, + setupAutofillInitDisconnectAction, }; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index cc2439ed83..fa77c49895 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -566,6 +566,7 @@ export default class MainBackground { this.logService, this.settingsService, this.userVerificationService, + this.configService, ); this.auditService = new AuditService(this.cryptoFunctionService, this.apiService); diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index fcaefc7c5e..de43e58560 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -1,5 +1,4 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -97,9 +96,9 @@ export default class RuntimeBackground { await closeUnlockPopout(); } + await this.notificationsService.updateConnection(msg.command === "loggedIn"); await this.main.refreshBadge(); await this.main.refreshMenu(false); - this.notificationsService.updateConnection(msg.command === "unlocked"); this.systemService.cancelProcessReload(); if (item) { @@ -133,11 +132,7 @@ export default class RuntimeBackground { await this.main.openPopup(); break; case "triggerAutofillScriptInjection": - await this.autofillService.injectAutofillScripts( - sender, - await this.configService.getFeatureFlag(FeatureFlag.AutofillV2), - await this.configService.getFeatureFlag(FeatureFlag.AutofillOverlay), - ); + await this.autofillService.injectAutofillScripts(sender.tab, sender.frameId); break; case "bgCollectPageDetails": await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId); @@ -325,6 +320,8 @@ export default class RuntimeBackground { private async checkOnInstalled() { setTimeout(async () => { + this.autofillService.loadAutofillScriptsOnInstall(); + if (this.onInstalledReason != null) { if (this.onInstalledReason === "install") { BrowserApi.createNewTab("https://bitwarden.com/browser-start/"); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index aa71d648e8..09f12c56f1 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -24,12 +24,6 @@ "matches": ["http://*/*", "https://*/*", "file:///*"], "run_at": "document_start" }, - { - "all_frames": false, - "js": ["content/message_handler.js"], - "matches": ["http://*/*", "https://*/*", "file:///*"], - "run_at": "document_start" - }, { "all_frames": true, "css": ["content/autofill.css"], diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index 2da36f0a5a..647e4cfdfb 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -23,11 +23,12 @@ const runtime = { removeListener: jest.fn(), }, sendMessage: jest.fn(), - getManifest: jest.fn(), + getManifest: jest.fn(() => ({ version: 2 })), getURL: jest.fn((path) => `chrome-extension://id/${path}`), connect: jest.fn(), onConnect: { addListener: jest.fn(), + removeListener: jest.fn(), }, };