diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index c438df932f..b2225bab4f 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2497,6 +2497,38 @@ "message": "Toggle collapse", "description": "Toggling an expand/collapse state." }, + "filelessImport": { + "message": "Import your data to Bitwarden?", + "description": "Default notification title for triggering a fileless import." + }, + "lpFilelessImport": { + "message": "Protect your LastPass data and import to Bitwarden?", + "description": "LastPass specific notification title for triggering a fileless import." + }, + "lpCancelFilelessImport": { + "message": "Save as unencrypted file", + "description": "LastPass specific notification button text for cancelling a fileless import." + }, + "startFilelessImport": { + "message": "Import to Bitwarden", + "description": "Notification button text for starting a fileless import." + }, + "importing": { + "message": "Importing...", + "description": "Notification message for when an import is in progress." + }, + "dataSuccessfullyImported": { + "message": "Data successfully imported!", + "description": "Notification message for when an import has completed successfully." + }, + "dataImportFailed": { + "message": "Error importing. Check console for details.", + "description": "Notification message for when an import has failed." + }, + "importNetworkError": { + "message": "Network error encountered during import.", + "description": "Notification message for when an import has failed due to a network error." + }, "aliasDomain": { "message": "Alias domain" }, diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts new file mode 100644 index 0000000000..e8c4e63ea9 --- /dev/null +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -0,0 +1,12 @@ +import AddChangePasswordQueueMessage from "../../notification/models/add-change-password-queue-message"; +import AddLoginQueueMessage from "../../notification/models/add-login-queue-message"; +import AddRequestFilelessImportQueueMessage from "../../notification/models/add-request-fileless-import-queue-message"; +import AddUnlockVaultQueueMessage from "../../notification/models/add-unlock-vault-queue-message"; + +type NotificationQueueMessageItem = + | AddLoginQueueMessage + | AddChangePasswordQueueMessage + | AddUnlockVaultQueueMessage + | AddRequestFilelessImportQueueMessage; + +export { NotificationQueueMessageItem }; diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 5f6522f59f..9644fd0927 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -20,18 +20,20 @@ import { NOTIFICATION_BAR_LIFESPAN_MS } from "../constants"; import AddChangePasswordQueueMessage from "../notification/models/add-change-password-queue-message"; import AddLoginQueueMessage from "../notification/models/add-login-queue-message"; import AddLoginRuntimeMessage from "../notification/models/add-login-runtime-message"; +import AddRequestFilelessImportQueueMessage from "../notification/models/add-request-fileless-import-queue-message"; import AddUnlockVaultQueueMessage from "../notification/models/add-unlock-vault-queue-message"; import ChangePasswordRuntimeMessage from "../notification/models/change-password-runtime-message"; import LockedVaultPendingNotificationsItem from "../notification/models/locked-vault-pending-notifications-item"; -import { NotificationQueueMessageType } from "../notification/models/notification-queue-message-type"; +import { + NotificationQueueMessageType, + NotificationTypes, +} from "../notification/models/notification-queue-message-type"; import { AutofillService } from "../services/abstractions/autofill.service"; +import { NotificationQueueMessageItem } from "./abstractions/notification.background"; + export default class NotificationBackground { - private notificationQueue: ( - | AddLoginQueueMessage - | AddChangePasswordQueueMessage - | AddUnlockVaultQueueMessage - )[] = []; + private notificationQueue: NotificationQueueMessageItem[] = []; constructor( private autofillService: AutofillService, @@ -162,53 +164,49 @@ export default class NotificationBackground { } private async doNotificationQueueCheck(tab: chrome.tabs.Tab): Promise { - if (tab == null) { + const tabDomain = Utils.getDomain(tab?.url); + if (!tabDomain) { return; } - const tabDomain = Utils.getDomain(tab.url); - if (tabDomain == null) { - return; + const queueMessage = this.notificationQueue.find( + (message) => message.tab.id === tab.id && message.domain === tabDomain, + ); + if (queueMessage) { + this.sendNotificationQueueMessage(tab, queueMessage); + } + } + + private async sendNotificationQueueMessage( + tab: chrome.tabs.Tab, + notificationQueueMessage: NotificationQueueMessageItem, + ) { + const notificationType = notificationQueueMessage.type; + const webVaultURL = + notificationType !== NotificationQueueMessageType.UnlockVault + ? this.environmentService.getWebVaultUrl() + : null; + const typeData: Record = { + isVaultLocked: notificationQueueMessage.wasVaultLocked, + theme: await this.getCurrentTheme(), + webVaultURL, + }; + + switch (notificationType) { + case NotificationQueueMessageType.AddLogin: + typeData.removeIndividualVault = await this.removeIndividualVault(); + break; + case NotificationQueueMessageType.RequestFilelessImport: + typeData.importType = ( + notificationQueueMessage as AddRequestFilelessImportQueueMessage + ).importType; + break; } - for (let i = 0; i < this.notificationQueue.length; i++) { - if ( - this.notificationQueue[i].tab.id !== tab.id || - this.notificationQueue[i].domain !== tabDomain - ) { - continue; - } - - if (this.notificationQueue[i].type === NotificationQueueMessageType.AddLogin) { - BrowserApi.tabSendMessageData(tab, "openNotificationBar", { - type: "add", - typeData: { - isVaultLocked: this.notificationQueue[i].wasVaultLocked, - theme: await this.getCurrentTheme(), - removeIndividualVault: await this.removeIndividualVault(), - webVaultURL: await this.environmentService.getWebVaultUrl(), - }, - }); - } else if (this.notificationQueue[i].type === NotificationQueueMessageType.ChangePassword) { - BrowserApi.tabSendMessageData(tab, "openNotificationBar", { - type: "change", - typeData: { - isVaultLocked: this.notificationQueue[i].wasVaultLocked, - theme: await this.getCurrentTheme(), - webVaultURL: await this.environmentService.getWebVaultUrl(), - }, - }); - } else if (this.notificationQueue[i].type === NotificationQueueMessageType.UnlockVault) { - BrowserApi.tabSendMessageData(tab, "openNotificationBar", { - type: "unlock", - typeData: { - isVaultLocked: this.notificationQueue[i].wasVaultLocked, - theme: await this.getCurrentTheme(), - }, - }); - } - break; - } + await BrowserApi.tabSendMessageData(tab, "openNotificationBar", { + type: NotificationTypes[notificationType], + typeData, + }); } private async getCurrentTheme() { @@ -351,11 +349,28 @@ export default class NotificationBackground { } const loginDomain = Utils.getDomain(tab.url); - if (!loginDomain) { + if (loginDomain) { + this.pushUnlockVaultToQueue(loginDomain, tab); + } + } + + /** + * Sets up a notification to request a fileless import when the user + * attempts to trigger an import from a third party website. + * + * @param tab - The tab that we are sending the notification to + * @param importType - The type of import that is being requested + */ + async requestFilelessImport(tab: chrome.tabs.Tab, importType: string) { + const currentAuthStatus = await this.authService.getAuthStatus(); + if (currentAuthStatus !== AuthenticationStatus.Unlocked || this.notificationQueue.length) { return; } - this.pushUnlockVaultToQueue(loginDomain, tab); + const loginDomain = Utils.getDomain(tab.url); + if (loginDomain) { + this.pushRequestFilelessImportToQueue(loginDomain, tab, importType); + } } private async pushChangePasswordToQueue( @@ -389,6 +404,32 @@ export default class NotificationBackground { expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds wasVaultLocked: true, }; + await this.sendNotificationQueueMessage(tab, message); + } + + /** + * Pushes a request to start a fileless import to the notification queue. + * This will display a notification bar to the user, prompting them to + * start the import. + * + * @param loginDomain - The domain of the tab that we are sending the notification to + * @param tab - The tab that we are sending the notification to + * @param importType - The type of import that is being requested + */ + private async pushRequestFilelessImportToQueue( + loginDomain: string, + tab: chrome.tabs.Tab, + importType?: string, + ) { + this.removeTabFromNotificationQueue(tab); + const message: AddRequestFilelessImportQueueMessage = { + type: NotificationQueueMessageType.RequestFilelessImport, + domain: loginDomain, + tab, + expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds + wasVaultLocked: false, + importType, + }; this.notificationQueue.push(message); await this.checkNotificationQueue(tab); this.removeTabFromNotificationQueue(tab); diff --git a/apps/browser/src/autofill/content/notification-bar.ts b/apps/browser/src/autofill/content/notification-bar.ts index c68467d050..78414e8be7 100644 --- a/apps/browser/src/autofill/content/notification-bar.ts +++ b/apps/browser/src/autofill/content/notification-bar.ts @@ -847,6 +847,7 @@ async function loadNotificationBar() { theme: typeData.theme, removeIndividualVault: typeData.removeIndividualVault, webVaultURL: typeData.webVaultURL, + importType: typeData.importType, }; const barQueryString = new URLSearchParams(barQueryParams).toString(); const barPage = "notification/bar.html?" + barQueryString; diff --git a/apps/browser/src/autofill/jest/autofill-mocks.ts b/apps/browser/src/autofill/jest/autofill-mocks.ts index de84cef249..a819d848bc 100644 --- a/apps/browser/src/autofill/jest/autofill-mocks.ts +++ b/apps/browser/src/autofill/jest/autofill-mocks.ts @@ -250,6 +250,7 @@ function createPortSpyMock(name: string) { addListener: jest.fn(), }, postMessage: jest.fn(), + disconnect: jest.fn(), sender: { tab: createChromeTabMock(), }, diff --git a/apps/browser/src/autofill/jest/testing-utils.ts b/apps/browser/src/autofill/jest/testing-utils.ts index a2bd3d9091..15f3121c2b 100644 --- a/apps/browser/src/autofill/jest/testing-utils.ts +++ b/apps/browser/src/autofill/jest/testing-utils.ts @@ -32,6 +32,15 @@ function sendExtensionRuntimeMessage( ); } +function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) { + (chrome.runtime.onConnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach( + (call) => { + const callback = call[0]; + callback(port); + }, + ); +} + function sendPortMessage(port: chrome.runtime.Port, message: any) { (port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; @@ -94,6 +103,7 @@ export { flushPromises, postWindowMessage, sendExtensionRuntimeMessage, + triggerRuntimeOnConnectEvent, sendPortMessage, triggerPortOnDisconnectEvent, triggerWindowOnFocusedChangedEvent, diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html index 0b7e4c8e0e..e03ea91509 100644 --- a/apps/browser/src/autofill/notification/bar.html +++ b/apps/browser/src/autofill/notification/bar.html @@ -60,4 +60,14 @@ + + diff --git a/apps/browser/src/autofill/notification/bar.scss b/apps/browser/src/autofill/notification/bar.scss index 20615e024a..97afd94974 100644 --- a/apps/browser/src/autofill/notification/bar.scss +++ b/apps/browser/src/autofill/notification/bar.scss @@ -145,6 +145,18 @@ button { font-family: $font-family-sans-serif; } +.success-message { + @include themify($themes) { + color: themed("successColor"); + } +} + +.error-message { + @include themify($themes) { + color: themed("errorColor"); + } +} + @media screen and (max-width: 768px) { #select-folder { display: none; diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index f4110d6dad..bc680c47dc 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -1,9 +1,14 @@ import type { Jsonify } from "type-fest"; +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { FilelessImportPort, FilelessImportType } from "../../tools/enums/fileless-import.enums"; + require("./bar.scss"); +const logService = new ConsoleLogService(false); + document.addEventListener("DOMContentLoaded", () => { // delay 50ms so that we get proper body dimensions setTimeout(load, 50); @@ -30,6 +35,11 @@ function load() { notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"), notificationUnlock: chrome.i18n.getMessage("notificationUnlock"), notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"), + filelessImport: chrome.i18n.getMessage("filelessImport"), + lpFilelessImport: chrome.i18n.getMessage("lpFilelessImport"), + cancelFilelessImport: chrome.i18n.getMessage("no"), + lpCancelFilelessImport: chrome.i18n.getMessage("lpCancelFilelessImport"), + startFilelessImport: chrome.i18n.getMessage("startFilelessImport"), }; const logoLink = document.getElementById("logo-link") as HTMLAnchorElement; @@ -74,6 +84,7 @@ function load() { changeTemplate.content.getElementById("change-text").textContent = i18n.notificationChangeDesc; + // i18n for "Unlock" (unlock extension) template const unlockTemplate = document.getElementById("template-unlock") as HTMLTemplateElement; const unlockButton = unlockTemplate.content.getElementById("unlock-vault"); @@ -81,6 +92,22 @@ function load() { unlockTemplate.content.getElementById("unlock-text").textContent = i18n.notificationUnlockDesc; + // i18n for "Fileless Import" (fileless-import) template + const isLpImport = getQueryVariable("importType") === FilelessImportType.LP; + const importTemplate = document.getElementById("template-fileless-import") as HTMLTemplateElement; + + const startImportButton = importTemplate.content.getElementById("start-fileless-import"); + startImportButton.textContent = i18n.startFilelessImport; + + const cancelImportButton = importTemplate.content.getElementById("cancel-fileless-import"); + cancelImportButton.textContent = isLpImport + ? i18n.lpCancelFilelessImport + : i18n.cancelFilelessImport; + + importTemplate.content.getElementById("fileless-import-text").textContent = isLpImport + ? i18n.lpFilelessImport + : i18n.filelessImport; + // i18n for body content const closeButton = document.getElementById("close-button"); closeButton.title = i18n.close; @@ -91,6 +118,8 @@ function load() { handleTypeChange(); } else if (getQueryVariable("type") === "unlock") { handleTypeUnlock(); + } else if (getQueryVariable("type") === "fileless-import") { + handleTypeFilelessImport(); } closeButton.addEventListener("click", (e) => { @@ -194,6 +223,54 @@ function handleTypeUnlock() { }); } +/** + * Sets up a port to communicate with the fileless importer content script. + * This connection to the background script is used to trigger the action of + * downloading the CSV file from the LP importer or importing the data into + * the Bitwarden vault. + */ +function handleTypeFilelessImport() { + const importType = getQueryVariable("importType"); + const port = chrome.runtime.connect({ name: FilelessImportPort.NotificationBar }); + setContent(document.getElementById("template-fileless-import") as HTMLTemplateElement); + + const startFilelessImportButton = document.getElementById("start-fileless-import"); + const startFilelessImport = () => { + port.postMessage({ command: "startFilelessImport", importType }); + document.getElementById("fileless-import-buttons").textContent = + chrome.i18n.getMessage("importing"); + startFilelessImportButton.removeEventListener("click", startFilelessImport); + }; + startFilelessImportButton.addEventListener("click", startFilelessImport); + + const cancelFilelessImportButton = document.getElementById("cancel-fileless-import"); + cancelFilelessImportButton.addEventListener("click", () => { + port.postMessage({ command: "cancelFilelessImport", importType }); + }); + + const handlePortMessage = (msg: any) => { + if (msg.command !== "filelessImportCompleted" && msg.command !== "filelessImportFailed") { + return; + } + + port.disconnect(); + + if (msg.command === "filelessImportCompleted") { + document.getElementById("fileless-import-buttons").textContent = chrome.i18n.getMessage( + "dataSuccessfullyImported", + ); + document.getElementById("fileless-import-buttons").classList.add("success-message"); + return; + } + + document.getElementById("fileless-import-buttons").textContent = + chrome.i18n.getMessage("dataImportFailed"); + document.getElementById("fileless-import-buttons").classList.add("error-message"); + logService.error(`Error Encountered During Import: ${msg.importErrorMessage}`); + }; + port.onMessage.addListener(handlePortMessage); +} + function setContent(template: HTMLTemplateElement) { const content = document.getElementById("content"); while (content.firstChild) { diff --git a/apps/browser/src/autofill/notification/models/add-request-fileless-import-queue-message.ts b/apps/browser/src/autofill/notification/models/add-request-fileless-import-queue-message.ts new file mode 100644 index 0000000000..4b955ce2a5 --- /dev/null +++ b/apps/browser/src/autofill/notification/models/add-request-fileless-import-queue-message.ts @@ -0,0 +1,7 @@ +import NotificationQueueMessage from "./notification-queue-message"; +import { NotificationQueueMessageType } from "./notification-queue-message-type"; + +export default class AddRequestFilelessImportQueueMessage extends NotificationQueueMessage { + type: NotificationQueueMessageType.RequestFilelessImport; + importType?: string; +} diff --git a/apps/browser/src/autofill/notification/models/notification-queue-message-type.ts b/apps/browser/src/autofill/notification/models/notification-queue-message-type.ts index 2ce1a1840d..8034e7cfd7 100644 --- a/apps/browser/src/autofill/notification/models/notification-queue-message-type.ts +++ b/apps/browser/src/autofill/notification/models/notification-queue-message-type.ts @@ -1,5 +1,15 @@ -export enum NotificationQueueMessageType { +enum NotificationQueueMessageType { AddLogin = 0, ChangePassword = 1, UnlockVault = 2, + RequestFilelessImport = 3, } + +const NotificationTypes = { + [NotificationQueueMessageType.AddLogin]: "add", + [NotificationQueueMessageType.ChangePassword]: "change", + [NotificationQueueMessageType.UnlockVault]: "unlock", + [NotificationQueueMessageType.RequestFilelessImport]: "fileless-import", +} as const; + +export { NotificationQueueMessageType, NotificationTypes }; diff --git a/apps/browser/src/autofill/shared/styles/variables.scss b/apps/browser/src/autofill/shared/styles/variables.scss index 318bc34025..3ae3cfab8e 100644 --- a/apps/browser/src/autofill/shared/styles/variables.scss +++ b/apps/browser/src/autofill/shared/styles/variables.scss @@ -24,6 +24,12 @@ $solarizedDarkBase2: #eee8d5; $solarizedDarkCyan: #2aa198; $solarizedDarkGreen: #859900; +$success-color-light: #017e45; +$success-color-dark: #8db89b; + +$error-color-light: #c83522; +$error-color-dark: #ee9792; + $themes: ( light: ( textColor: $text-color, @@ -37,6 +43,8 @@ $themes: ( inputBackgroundColor: #ffffff, borderColor: $border-color, focusOutlineColor: $focus-outline-color, + successColor: $success-color-light, + errorColor: $error-color-light, ), dark: ( textColor: #ffffff, @@ -50,6 +58,8 @@ $themes: ( inputBackgroundColor: #2f343d, borderColor: #4c525f, focusOutlineColor: $focus-outline-color, + successColor: $success-color-dark, + errorColor: $error-color-dark, ), nord: ( textColor: $nord5, @@ -63,6 +73,7 @@ $themes: ( inputBackgroundColor: $nord2, borderColor: $nord0, focusOutlineColor: $focus-outline-color, + successColor: $success-color-dark, ), solarizedDark: ( textColor: $solarizedDarkBase2, @@ -77,6 +88,7 @@ $themes: ( inputBackgroundColor: $solarizedDarkBase01, borderColor: $solarizedDarkBase2, focusOutlineColor: $focus-outline-color, + successColor: $success-color-dark, ), ); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 0513a750e1..19ad2bff9f 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -166,6 +166,7 @@ import { BackgroundMemoryStorageService } from "../platform/storage/background-m import { BrowserSendService } from "../services/browser-send.service"; import { BrowserSettingsService } from "../services/browser-settings.service"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; +import FilelessImporterBackground from "../tools/background/fileless-importer.background"; import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service"; import { Fido2Service as Fido2ServiceAbstraction } from "../vault/services/abstractions/fido2.service"; import { BrowserFolderService } from "../vault/services/browser-folder.service"; @@ -262,6 +263,7 @@ export default class MainBackground { private idleBackground: IdleBackground; private notificationBackground: NotificationBackground; private overlayBackground: OverlayBackground; + private filelessImporterBackground: FilelessImporterBackground; private runtimeBackground: RuntimeBackground; private tabsBackground: TabsBackground; private webRequestBackground: WebRequestBackground; @@ -723,6 +725,14 @@ export default class MainBackground { this.stateService, this.i18nService, ); + this.filelessImporterBackground = new FilelessImporterBackground( + this.configService, + this.authService, + this.policyService, + this.notificationBackground, + this.importService, + this.syncService, + ); this.tabsBackground = new TabsBackground( this, this.notificationBackground, @@ -805,6 +815,7 @@ export default class MainBackground { await (this.eventUploadService as EventUploadService).init(true); await this.runtimeBackground.init(); await this.notificationBackground.init(); + this.filelessImporterBackground.init(); await this.commandsBackground.init(); this.configService.init(); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 66f65a9850..12f941a934 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -35,6 +35,12 @@ "css": ["content/autofill.css"], "matches": ["http://*/*", "https://*/*", "file:///*"], "run_at": "document_end" + }, + { + "all_frames": false, + "js": ["content/lp-fileless-importer.js"], + "matches": ["https://lastpass.com/export.php"], + "run_at": "document_start" } ], "background": { diff --git a/apps/browser/src/tools/background/abstractions/fileless-importer.background.ts b/apps/browser/src/tools/background/abstractions/fileless-importer.background.ts new file mode 100644 index 0000000000..2ade5bf767 --- /dev/null +++ b/apps/browser/src/tools/background/abstractions/fileless-importer.background.ts @@ -0,0 +1,34 @@ +import { FilelessImportTypeKeys } from "../../enums/fileless-import.enums"; + +type FilelessImportPortMessage = { + command?: string; + importType?: FilelessImportTypeKeys; + data?: string; +}; + +type FilelessImportPortMessageHandlerParams = { + message: FilelessImportPortMessage; + port: chrome.runtime.Port; +}; + +type ImportNotificationMessageHandlers = { + [key: string]: ({ message, port }: FilelessImportPortMessageHandlerParams) => void; + cancelFilelessImport: ({ message, port }: FilelessImportPortMessageHandlerParams) => void; +}; + +type LpImporterMessageHandlers = { + [key: string]: ({ message, port }: FilelessImportPortMessageHandlerParams) => void; + displayLpImportNotification: ({ port }: { port: chrome.runtime.Port }) => void; + startLpImport: ({ message }: { message: FilelessImportPortMessage }) => void; +}; + +interface FilelessImporterBackground { + init(): void; +} + +export { + FilelessImportPortMessage, + ImportNotificationMessageHandlers, + LpImporterMessageHandlers, + FilelessImporterBackground, +}; diff --git a/apps/browser/src/tools/background/fileless-importer.background.spec.ts b/apps/browser/src/tools/background/fileless-importer.background.spec.ts new file mode 100644 index 0000000000..a119e20405 --- /dev/null +++ b/apps/browser/src/tools/background/fileless-importer.background.spec.ts @@ -0,0 +1,291 @@ +import { mock } from "jest-mock-extended"; + +import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { Importer, ImportResult, ImportServiceAbstraction } from "@bitwarden/importer/core"; + +import NotificationBackground from "../../autofill/background/notification.background"; +import { createPortSpyMock } from "../../autofill/jest/autofill-mocks"; +import { + flushPromises, + sendPortMessage, + triggerRuntimeOnConnectEvent, +} from "../../autofill/jest/testing-utils"; +import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import.enums"; + +import FilelessImporterBackground from "./fileless-importer.background"; + +describe("FilelessImporterBackground ", () => { + let filelessImporterBackground: FilelessImporterBackground; + const configService = mock(); + const authService = mock(); + const policyService = mock(); + const notificationBackground = mock(); + const importService = mock(); + const syncService = mock(); + + beforeEach(() => { + filelessImporterBackground = new FilelessImporterBackground( + configService, + authService, + policyService, + notificationBackground, + importService, + syncService, + ); + filelessImporterBackground.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("init", () => { + it("sets up the port message listeners on initialization of the class", () => { + expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + describe("handle ports onConnect", () => { + let lpImporterPort: chrome.runtime.Port; + + beforeEach(() => { + lpImporterPort = createPortSpyMock(FilelessImportPort.LpImporter); + jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked); + jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true); + jest + .spyOn(filelessImporterBackground as any, "removeIndividualVault") + .mockResolvedValue(false); + }); + + it("ignores the port connection if the port name is not present in the set of filelessImportNames", async () => { + const port = createPortSpyMock("some-other-port"); + + triggerRuntimeOnConnectEvent(port); + await flushPromises(); + + expect(port.postMessage).not.toHaveBeenCalled(); + }); + + it("posts a message to the port indicating that the fileless import feature is disabled if the user's auth status is not unlocked", async () => { + jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Locked); + + triggerRuntimeOnConnectEvent(lpImporterPort); + await flushPromises(); + + expect(lpImporterPort.postMessage).toHaveBeenCalledWith({ + command: "verifyFeatureFlag", + filelessImportEnabled: false, + }); + }); + + it("posts a message to the port indicating that the fileless import feature is disabled if the user's policy removes individual vaults", async () => { + jest + .spyOn(filelessImporterBackground as any, "removeIndividualVault") + .mockResolvedValue(true); + + triggerRuntimeOnConnectEvent(lpImporterPort); + await flushPromises(); + + expect(lpImporterPort.postMessage).toHaveBeenCalledWith({ + command: "verifyFeatureFlag", + filelessImportEnabled: false, + }); + }); + + it("posts a message to the port indicating that the fileless import feature is disabled if the feature flag is turned off", async () => { + jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(false); + + triggerRuntimeOnConnectEvent(lpImporterPort); + await flushPromises(); + + expect(lpImporterPort.postMessage).toHaveBeenCalledWith({ + command: "verifyFeatureFlag", + filelessImportEnabled: false, + }); + }); + + it("posts a message to the port indicating that the fileless import feature is enabled", async () => { + triggerRuntimeOnConnectEvent(lpImporterPort); + await flushPromises(); + + expect(lpImporterPort.postMessage).toHaveBeenCalledWith({ + command: "verifyFeatureFlag", + filelessImportEnabled: true, + }); + }); + }); + + describe("port messages", () => { + let notificationPort: chrome.runtime.Port; + let lpImporterPort: chrome.runtime.Port; + + beforeEach(async () => { + jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked); + jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true); + jest + .spyOn(filelessImporterBackground as any, "removeIndividualVault") + .mockResolvedValue(false); + triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.NotificationBar)); + triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.LpImporter)); + await flushPromises(); + notificationPort = filelessImporterBackground["importNotificationsPort"]; + lpImporterPort = filelessImporterBackground["lpImporterPort"]; + }); + + it("skips handling a message if a message handler is not associated with the port message command", () => { + sendPortMessage(notificationPort, { command: "commandNotFound" }); + + expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); + }); + + describe("import notification port messages", () => { + describe("startFilelessImport", () => { + it("sends a message to start the LastPass fileless import within the content script", () => { + sendPortMessage(notificationPort, { + command: "startFilelessImport", + importType: FilelessImportType.LP, + }); + + expect(lpImporterPort.postMessage).toHaveBeenCalledWith({ + command: "startLpFilelessImport", + }); + }); + }); + + describe("cancelFilelessImport", () => { + it("sends a message to close the notification bar", async () => { + sendPortMessage(notificationPort, { command: "cancelFilelessImport" }); + + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( + notificationPort.sender.tab.id, + { + command: "closeNotificationBar", + }, + null, + expect.anything(), + ); + expect(lpImporterPort.postMessage).not.toHaveBeenCalledWith({ + command: "triggerCsvDownload", + }); + }); + + it("sends a message to trigger a download of the LP importer CSV", () => { + sendPortMessage(notificationPort, { + command: "cancelFilelessImport", + importType: FilelessImportType.LP, + }); + + expect(lpImporterPort.postMessage).toHaveBeenCalledWith({ + command: "triggerCsvDownload", + }); + expect(lpImporterPort.disconnect).toHaveBeenCalled(); + }); + }); + }); + + describe("lp importer port messages", () => { + describe("displayLpImportNotification", () => { + it("creates a request fileless import notification", async () => { + jest.spyOn(filelessImporterBackground["notificationBackground"], "requestFilelessImport"); + + sendPortMessage(lpImporterPort, { + command: "displayLpImportNotification", + }); + await flushPromises(); + + expect( + filelessImporterBackground["notificationBackground"].requestFilelessImport, + ).toHaveBeenCalledWith(lpImporterPort.sender.tab, FilelessImportType.LP); + }); + }); + + describe("startLpImport", () => { + it("ignores the message if the message does not contain data", () => { + sendPortMessage(lpImporterPort, { + command: "startLpImport", + }); + + expect(filelessImporterBackground["importService"].import).not.toHaveBeenCalled(); + }); + + it("triggers the import of the LastPass vault", async () => { + const data = "url,username,password"; + const importer = mock(); + jest + .spyOn(filelessImporterBackground["importService"], "getImporter") + .mockReturnValue(importer); + jest.spyOn(filelessImporterBackground["importService"], "import").mockResolvedValue( + mock({ + success: true, + }), + ); + jest.spyOn(filelessImporterBackground["syncService"], "fullSync"); + + sendPortMessage(lpImporterPort, { + command: "startLpImport", + data, + }); + await flushPromises(); + + expect(filelessImporterBackground["importService"].import).toHaveBeenCalledWith( + importer, + data, + null, + null, + false, + ); + expect( + filelessImporterBackground["importNotificationsPort"].postMessage, + ).toHaveBeenCalledWith({ command: "filelessImportCompleted" }); + expect(filelessImporterBackground["syncService"].fullSync).toHaveBeenCalledWith(true); + }); + + it("posts a failed message if the import fails", async () => { + const data = "url,username,password"; + const importer = mock(); + jest + .spyOn(filelessImporterBackground["importService"], "getImporter") + .mockReturnValue(importer); + jest + .spyOn(filelessImporterBackground["importService"], "import") + .mockImplementation(() => { + throw new Error("error"); + }); + jest.spyOn(filelessImporterBackground["syncService"], "fullSync"); + + sendPortMessage(lpImporterPort, { + command: "startLpImport", + data, + }); + await flushPromises(); + + expect( + filelessImporterBackground["importNotificationsPort"].postMessage, + ).toHaveBeenCalledWith({ command: "filelessImportFailed" }); + }); + }); + }); + }); + + describe("handleImporterPortDisconnect", () => { + it("resets the port properties to null", () => { + const lpImporterPort = createPortSpyMock(FilelessImportPort.LpImporter); + const notificationPort = createPortSpyMock(FilelessImportPort.NotificationBar); + filelessImporterBackground["lpImporterPort"] = lpImporterPort; + filelessImporterBackground["importNotificationsPort"] = notificationPort; + + filelessImporterBackground["handleImporterPortDisconnect"](lpImporterPort); + + expect(filelessImporterBackground["lpImporterPort"]).toBeNull(); + expect(filelessImporterBackground["importNotificationsPort"]).not.toBeNull(); + + filelessImporterBackground["handleImporterPortDisconnect"](notificationPort); + + expect(filelessImporterBackground["importNotificationsPort"]).toBeNull(); + }); + }); +}); diff --git a/apps/browser/src/tools/background/fileless-importer.background.ts b/apps/browser/src/tools/background/fileless-importer.background.ts new file mode 100644 index 0000000000..f7f32c67d5 --- /dev/null +++ b/apps/browser/src/tools/background/fileless-importer.background.ts @@ -0,0 +1,253 @@ +import { firstValueFrom } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { ImportServiceAbstraction } from "@bitwarden/importer/core"; + +import NotificationBackground from "../../autofill/background/notification.background"; +import { BrowserApi } from "../../platform/browser/browser-api"; +import { + FilelessImportPort, + FilelessImportType, + FilelessImportTypeKeys, +} from "../enums/fileless-import.enums"; + +import { + ImportNotificationMessageHandlers, + LpImporterMessageHandlers, + FilelessImporterBackground as FilelessImporterBackgroundInterface, + FilelessImportPortMessage, +} from "./abstractions/fileless-importer.background"; + +class FilelessImporterBackground implements FilelessImporterBackgroundInterface { + private static readonly filelessImporterPortNames: Set = new Set([ + FilelessImportPort.LpImporter, + FilelessImportPort.NotificationBar, + ]); + private importNotificationsPort: chrome.runtime.Port; + private lpImporterPort: chrome.runtime.Port; + private readonly importNotificationsPortMessageHandlers: ImportNotificationMessageHandlers = { + startFilelessImport: ({ message }) => this.startFilelessImport(message.importType), + cancelFilelessImport: ({ message, port }) => + this.cancelFilelessImport(message.importType, port.sender), + }; + private readonly lpImporterPortMessageHandlers: LpImporterMessageHandlers = { + displayLpImportNotification: ({ port }) => + this.displayFilelessImportNotification(port.sender.tab, FilelessImportType.LP), + startLpImport: ({ message }) => this.triggerLpImport(message.data), + }; + + /** + * Creates a new instance of the fileless importer background logic. + * + * @param configService - Identifies if the feature flag is enabled. + * @param authService - Verifies if the auth status of the user. + * @param policyService - Identifies if the user account has a policy that disables personal ownership. + * @param notificationBackground - Used to inject the notification bar into the tab. + * @param importService - Used to import the export data into the vault. + * @param syncService - Used to trigger a full sync after the import is completed. + */ + constructor( + private configService: ConfigServiceAbstraction, + private authService: AuthService, + private policyService: PolicyService, + private notificationBackground: NotificationBackground, + private importService: ImportServiceAbstraction, + private syncService: SyncService, + ) {} + + /** + * Initializes the fileless importer background logic. + */ + init() { + this.setupPortMessageListeners(); + } + + /** + * Starts an import of the export data pulled from the tab. + * + * @param importType - The type of import to start. Identifies the used content script. + */ + private startFilelessImport(importType: FilelessImportTypeKeys) { + if (importType === FilelessImportType.LP) { + this.lpImporterPort?.postMessage({ command: "startLpFilelessImport" }); + } + } + + /** + * Cancels an import of the export data pulled from the tab. This closes any + * existing notifications that are present in the tab, and triggers importer + * specific behavior based on the import type. + * + * @param importType - The type of import to cancel. Identifies the used content script. + * @param sender - The sender of the message. + */ + private async cancelFilelessImport( + importType: FilelessImportTypeKeys, + sender: chrome.runtime.MessageSender, + ) { + if (importType === FilelessImportType.LP) { + this.triggerLpImporterCsvDownload(); + } + + await BrowserApi.tabSendMessage(sender.tab, { command: "closeNotificationBar" }); + } + + /** + * Injects the notification bar into the passed tab. + * + * @param tab + * @param importType + */ + private async displayFilelessImportNotification(tab: chrome.tabs.Tab, importType: string) { + await this.notificationBackground.requestFilelessImport(tab, importType); + } + + /** + * Triggers the download of the CSV file from the LP importer. This is triggered + * when the user opts to not save the export to Bitwarden within the notification bar. + */ + private triggerLpImporterCsvDownload() { + this.lpImporterPort?.postMessage({ command: "triggerCsvDownload" }); + this.lpImporterPort?.disconnect(); + } + + /** + * Completes the import process for the LP importer. This is triggered when the + * user opts to save the export to Bitwarden within the notification bar. + * + * @param data - The export data to import. + * @param sender - The sender of the message. + */ + private async triggerLpImport(data: string) { + if (!data) { + return; + } + + const promptForPassword_callback = async () => ""; + const importer = this.importService.getImporter( + "lastpasscsv", + promptForPassword_callback, + null, + ); + + try { + const result = await this.importService.import(importer, data, null, null, false); + if (result.success) { + this.importNotificationsPort?.postMessage({ command: "filelessImportCompleted" }); + await this.syncService.fullSync(true); + } + } catch (error) { + this.importNotificationsPort?.postMessage({ + command: "filelessImportFailed", + importErrorMessage: Object.values(error).length + ? error + : chrome.i18n.getMessage("importNetworkError"), + }); + } + } + + /** + * Identifies if the user account has a policy that disables personal ownership. + */ + private async removeIndividualVault(): Promise { + return await firstValueFrom( + this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership), + ); + } + + /** + * Sets up onConnect listeners for the extension. + */ + private setupPortMessageListeners() { + chrome.runtime.onConnect.addListener(this.handlePortOnConnect); + } + + /** + * Handles connections from content scripts that affect the fileless importer behavior. + * Is used to facilitate the passing of data and user actions to enact the import + * of web content to the Bitwarden vault. Along with this, a check is made to ensure + * that the feature flag is enabled and the user is authenticated. + */ + private handlePortOnConnect = async (port: chrome.runtime.Port) => { + if (!FilelessImporterBackground.filelessImporterPortNames.has(port.name)) { + return; + } + + const filelessImportFeatureFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.BrowserFilelessImport, + ); + const userAuthStatus = await this.authService.getAuthStatus(); + const removeIndividualVault = await this.removeIndividualVault(); + const filelessImportEnabled = + filelessImportFeatureFlagEnabled && + userAuthStatus === AuthenticationStatus.Unlocked && + !removeIndividualVault; + port.postMessage({ command: "verifyFeatureFlag", filelessImportEnabled }); + + if (!filelessImportEnabled) { + return; + } + + port.onMessage.addListener(this.handleImporterPortMessage); + port.onDisconnect.addListener(this.handleImporterPortDisconnect); + + switch (port.name) { + case FilelessImportPort.LpImporter: + this.lpImporterPort = port; + break; + case FilelessImportPort.NotificationBar: + this.importNotificationsPort = port; + break; + } + }; + + /** + * Handles messages that are sent from fileless importer content scripts. + * @param message - The message that was sent. + * @param port - The port that the message was sent from. + */ + private handleImporterPortMessage = ( + message: FilelessImportPortMessage, + port: chrome.runtime.Port, + ) => { + let handler: CallableFunction | undefined; + + switch (port.name) { + case FilelessImportPort.LpImporter: + handler = this.lpImporterPortMessageHandlers[message.command]; + break; + case FilelessImportPort.NotificationBar: + handler = this.importNotificationsPortMessageHandlers[message.command]; + break; + } + + if (!handler) { + return; + } + + handler({ message, port }); + }; + + /** + * Handles disconnections from fileless importer content scripts. + * @param port - The port that was disconnected. + */ + private handleImporterPortDisconnect = (port: chrome.runtime.Port) => { + switch (port.name) { + case FilelessImportPort.LpImporter: + this.lpImporterPort = null; + break; + case FilelessImportPort.NotificationBar: + this.importNotificationsPort = null; + break; + } + }; +} + +export default FilelessImporterBackground; diff --git a/apps/browser/src/tools/content/abstractions/lp-fileless-importer.ts b/apps/browser/src/tools/content/abstractions/lp-fileless-importer.ts new file mode 100644 index 0000000000..018ea2c8d9 --- /dev/null +++ b/apps/browser/src/tools/content/abstractions/lp-fileless-importer.ts @@ -0,0 +1,25 @@ +type LpFilelessImporterMessage = { + command?: string; + data?: string; + filelessImportEnabled?: boolean; +}; + +type LpFilelessImporterMessageHandlerParams = { + message: LpFilelessImporterMessage; + port: chrome.runtime.Port; +}; + +type LpFilelessImporterMessageHandlers = { + [key: string]: ({ message, port }: LpFilelessImporterMessageHandlerParams) => void; + verifyFeatureFlag: ({ message }: { message: LpFilelessImporterMessage }) => void; + triggerCsvDownload: () => void; + startLpFilelessImport: () => void; +}; + +interface LpFilelessImporter { + init(): void; + handleFeatureFlagVerification(message: LpFilelessImporterMessage): void; + triggerCsvDownload(): void; +} + +export { LpFilelessImporterMessage, LpFilelessImporterMessageHandlers, LpFilelessImporter }; diff --git a/apps/browser/src/tools/content/lp-fileless-importer.spec.ts b/apps/browser/src/tools/content/lp-fileless-importer.spec.ts new file mode 100644 index 0000000000..0cc47a333a --- /dev/null +++ b/apps/browser/src/tools/content/lp-fileless-importer.spec.ts @@ -0,0 +1,223 @@ +import { mock } from "jest-mock-extended"; + +import { createPortSpyMock } from "../../autofill/jest/autofill-mocks"; +import { sendPortMessage } from "../../autofill/jest/testing-utils"; +import { FilelessImportPort } from "../enums/fileless-import.enums"; + +import { LpFilelessImporter } from "./abstractions/lp-fileless-importer"; + +describe("LpFilelessImporter", () => { + let lpFilelessImporter: LpFilelessImporter & { [key: string]: any }; + const portSpy: chrome.runtime.Port = createPortSpyMock(FilelessImportPort.LpImporter); + chrome.runtime.connect = jest.fn(() => portSpy); + + beforeEach(() => { + require("./lp-fileless-importer"); + lpFilelessImporter = (globalThis as any).lpFilelessImporter; + }); + + afterEach(() => { + (globalThis as any).lpFilelessImporter = undefined; + jest.clearAllMocks(); + jest.resetModules(); + Object.defineProperty(document, "readyState", { + value: "complete", + writable: true, + }); + }); + + describe("init", () => { + it("sets up the port connection with the background script", () => { + lpFilelessImporter.init(); + + expect(chrome.runtime.connect).toHaveBeenCalledWith({ + name: FilelessImportPort.LpImporter, + }); + }); + }); + + describe("handleFeatureFlagVerification", () => { + it("disconnects the message port when the fileless import feature is disabled", () => { + lpFilelessImporter.handleFeatureFlagVerification({ filelessImportEnabled: false }); + + expect(portSpy.disconnect).toHaveBeenCalled(); + }); + + it("injects a script element that suppresses the download of the LastPass export", () => { + const script = document.createElement("script"); + jest.spyOn(document, "createElement").mockReturnValue(script); + jest.spyOn(document.documentElement, "appendChild"); + + lpFilelessImporter.handleFeatureFlagVerification({ filelessImportEnabled: true }); + + expect(document.createElement).toHaveBeenCalledWith("script"); + expect(document.documentElement.appendChild).toHaveBeenCalled(); + expect(script.textContent).toContain( + "const defaultAppendChild = Element.prototype.appendChild;", + ); + }); + + it("sets up an event listener for DOMContentLoaded that triggers the importer when the document ready state is `loading`", () => { + Object.defineProperty(document, "readyState", { + value: "loading", + writable: true, + }); + const message = { + command: "verifyFeatureFlag", + filelessImportEnabled: true, + }; + jest.spyOn(document, "addEventListener"); + + lpFilelessImporter.handleFeatureFlagVerification(message); + + expect(document.addEventListener).toHaveBeenCalledWith( + "DOMContentLoaded", + (lpFilelessImporter as any).loadImporter, + ); + }); + + it("sets up a mutation observer to watch the document body for injection of the export content", () => { + const message = { + command: "verifyFeatureFlag", + filelessImportEnabled: true, + }; + jest.spyOn(document, "addEventListener"); + jest.spyOn(window, "MutationObserver").mockImplementationOnce(() => mock()); + + lpFilelessImporter.handleFeatureFlagVerification(message); + + expect(window.MutationObserver).toHaveBeenCalledWith( + (lpFilelessImporter as any).handleMutation, + ); + expect((lpFilelessImporter as any).mutationObserver.observe).toHaveBeenCalledWith( + document.body, + { childList: true, subtree: true }, + ); + }); + }); + + describe("triggerCsvDownload", () => { + it("posts a window message that triggers the download of the LastPass export", () => { + jest.spyOn(globalThis, "postMessage"); + + lpFilelessImporter.triggerCsvDownload(); + + expect(globalThis.postMessage).toHaveBeenCalledWith( + { command: "triggerCsvDownload" }, + "https://lastpass.com", + ); + }); + }); + + describe("handleMutation", () => { + beforeEach(() => { + lpFilelessImporter["mutationObserver"] = mock({ disconnect: jest.fn() }); + jest.spyOn(portSpy, "postMessage"); + }); + + it("ignores mutations that contain empty records", () => { + lpFilelessImporter["handleMutation"]([]); + + expect(portSpy.postMessage).not.toHaveBeenCalled(); + }); + + it("ignores mutations that have no added nodes in the mutation", () => { + lpFilelessImporter["handleMutation"]([{ addedNodes: [] }]); + + expect(portSpy.postMessage).not.toHaveBeenCalled(); + }); + + it("ignores mutations that have no added nodes with a tagname of `pre`", () => { + lpFilelessImporter["handleMutation"]([{ addedNodes: [{ nodeName: "div" }] }]); + + expect(portSpy.postMessage).not.toHaveBeenCalled(); + }); + + it("ignores mutations where the found `pre` element does not contain any textContent", () => { + lpFilelessImporter["handleMutation"]([{ addedNodes: [{ nodeName: "pre" }] }]); + + expect(portSpy.postMessage).not.toHaveBeenCalled(); + }); + + it("ignores mutations where the found `pre` element does not contain the expected header content", () => { + lpFilelessImporter["handleMutation"]([ + { addedNodes: [{ nodeName: "pre", textContent: "some other content" }] }, + ]); + + expect(portSpy.postMessage).not.toHaveBeenCalled(); + }); + + it("will store the export data, display the import notification, and disconnect the mutation observer when the export data is appended", () => { + const observerDisconnectSpy = jest.spyOn( + lpFilelessImporter["mutationObserver"], + "disconnect", + ); + + lpFilelessImporter["handleMutation"]([ + { addedNodes: [{ nodeName: "pre", textContent: "url,username,password" }] }, + ]); + + expect(lpFilelessImporter["exportData"]).toEqual("url,username,password"); + expect(portSpy.postMessage).toHaveBeenCalledWith({ command: "displayLpImportNotification" }); + expect(observerDisconnectSpy).toHaveBeenCalled(); + }); + }); + + describe("handlePortMessage", () => { + it("ignores messages that are not registered with the portMessageHandlers", () => { + const message = { command: "unknownCommand" }; + jest.spyOn(lpFilelessImporter, "handleFeatureFlagVerification"); + jest.spyOn(lpFilelessImporter, "triggerCsvDownload"); + + sendPortMessage(portSpy, message); + + expect(lpFilelessImporter.handleFeatureFlagVerification).not.toHaveBeenCalled(); + expect(lpFilelessImporter.triggerCsvDownload).not.toHaveBeenCalled(); + }); + + it("handles the port message that verifies the fileless import feature flag", () => { + const message = { command: "verifyFeatureFlag", filelessImportEnabled: true }; + jest.spyOn(lpFilelessImporter, "handleFeatureFlagVerification").mockImplementation(); + + sendPortMessage(portSpy, message); + + expect(lpFilelessImporter.handleFeatureFlagVerification).toHaveBeenCalledWith(message); + }); + + it("handles the port message that triggers the LastPass csv download", () => { + const message = { command: "triggerCsvDownload" }; + jest.spyOn(lpFilelessImporter, "triggerCsvDownload"); + + sendPortMessage(portSpy, message); + + expect(lpFilelessImporter.triggerCsvDownload).toHaveBeenCalled(); + }); + + describe("handles the port message that triggers the LastPass fileless import", () => { + beforeEach(() => { + jest.spyOn(lpFilelessImporter as any, "postPortMessage"); + }); + + it("skips the import of the export data is not populated", () => { + const message = { command: "startLpFilelessImport" }; + + sendPortMessage(portSpy, message); + + expect(lpFilelessImporter.postPortMessage).not.toHaveBeenCalled(); + }); + + it("starts the last pass fileless import", () => { + const message = { command: "startLpFilelessImport" }; + const exportData = "url,username,password"; + lpFilelessImporter["exportData"] = exportData; + + sendPortMessage(portSpy, message); + + expect(lpFilelessImporter.postPortMessage).toHaveBeenCalledWith({ + command: "startLpImport", + data: exportData, + }); + }); + }); + }); +}); diff --git a/apps/browser/src/tools/content/lp-fileless-importer.ts b/apps/browser/src/tools/content/lp-fileless-importer.ts new file mode 100644 index 0000000000..107159e5a5 --- /dev/null +++ b/apps/browser/src/tools/content/lp-fileless-importer.ts @@ -0,0 +1,197 @@ +import { FilelessImportPort } from "../enums/fileless-import.enums"; + +import { + LpFilelessImporter as LpFilelessImporterInterface, + LpFilelessImporterMessage, + LpFilelessImporterMessageHandlers, +} from "./abstractions/lp-fileless-importer"; + +class LpFilelessImporter implements LpFilelessImporterInterface { + private exportData: string; + private messagePort: chrome.runtime.Port; + private mutationObserver: MutationObserver; + private readonly portMessageHandlers: LpFilelessImporterMessageHandlers = { + verifyFeatureFlag: ({ message }) => this.handleFeatureFlagVerification(message), + triggerCsvDownload: () => this.triggerCsvDownload(), + startLpFilelessImport: () => this.startLpImport(), + }; + + /** + * Initializes the LP fileless importer. + */ + init() { + this.setupMessagePort(); + } + + /** + * Enacts behavior based on the feature flag verification message. If the feature flag is + * not enabled, the message port is disconnected. If the feature flag is enabled, the + * download of the CSV file is suppressed. + * + * @param message - The port message, contains the feature flag indicator. + */ + handleFeatureFlagVerification(message: LpFilelessImporterMessage) { + if (!message.filelessImportEnabled) { + this.messagePort?.disconnect(); + return; + } + + this.suppressDownload(); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", this.loadImporter); + return; + } + + this.loadImporter(); + } + + /** + * Posts a message to the LP importer to trigger the download of the CSV file. + */ + triggerCsvDownload() { + this.postWindowMessage({ command: "triggerCsvDownload" }); + } + + /** + * Suppresses the download of the CSV file by overriding the `download` attribute of the + * anchor element that is created by the LP importer. This is done by injecting a script + * into the page that overrides the `appendChild` method of the `Element` prototype. + */ + private suppressDownload() { + const script = document.createElement("script"); + script.textContent = ` + let csvDownload = ''; + let csvHref = ''; + const defaultAppendChild = Element.prototype.appendChild; + Element.prototype.appendChild = function (newChild) { + if (newChild.nodeName.toLowerCase() === 'a' && newChild.download) { + csvDownload = newChild.download; + csvHref = newChild.href; + newChild.setAttribute('href', 'javascript:void(0)'); + newChild.setAttribute('download', ''); + Element.prototype.appendChild = defaultAppendChild; + } + + return defaultAppendChild.call(this, newChild); + }; + + window.addEventListener('message', (event) => { + const command = event.data?.command; + if (event.source !== window || command !== 'triggerCsvDownload') { + return; + } + + const anchor = document.createElement('a'); + anchor.setAttribute('href', csvHref); + anchor.setAttribute('download', csvDownload); + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + }); + `; + document.documentElement.appendChild(script); + } + + /** + * Initializes the importing mechanism used to import the CSV file into Bitwarden. + * This is done by observing the DOM for the addition of the LP importer element. + */ + private loadImporter = () => { + this.mutationObserver = new MutationObserver(this.handleMutation); + this.mutationObserver.observe(document.body, { + childList: true, + subtree: true, + }); + }; + + /** + * Handles mutations that are observed by the mutation observer. When the exported data + * element is added to the DOM, the export data is extracted and the import prompt is + * displayed. + * + * @param mutations - The mutations that were observed. + */ + private handleMutation = (mutations: MutationRecord[]) => { + let textContent: string; + for (let index = 0; index < mutations?.length; index++) { + const mutation: MutationRecord = mutations[index]; + + textContent = Array.from(mutation.addedNodes) + .filter((node) => node.nodeName.toLowerCase() === "pre") + .map((node) => (node as HTMLPreElement).textContent?.trim()) + .find((text) => text?.indexOf("url,username,password") >= 0); + + if (textContent) { + break; + } + } + + if (textContent) { + this.exportData = textContent; + this.postPortMessage({ command: "displayLpImportNotification" }); + this.mutationObserver.disconnect(); + } + }; + + /** + * If the export data is present, sends a message to the background with + * the export data to start the import process. + */ + private startLpImport() { + if (!this.exportData) { + return; + } + + this.postPortMessage({ command: "startLpImport", data: this.exportData }); + this.messagePort?.disconnect(); + } + + /** + * Posts a message to the background script. + * + * @param message - The message to post. + */ + private postPortMessage(message: LpFilelessImporterMessage) { + this.messagePort?.postMessage(message); + } + + /** + * Posts a message to the global context of the page. + * + * @param message - The message to post. + */ + private postWindowMessage(message: LpFilelessImporterMessage) { + globalThis.postMessage(message, "https://lastpass.com"); + } + + /** + * Sets up the message port that is used to facilitate communication between the + * background script and the content script. + */ + private setupMessagePort() { + this.messagePort = chrome.runtime.connect({ name: FilelessImportPort.LpImporter }); + this.messagePort.onMessage.addListener(this.handlePortMessage); + } + + /** + * Handles messages that are sent from the background script. + * + * @param message - The message that was sent. + * @param port - The port that the message was sent from. + */ + private handlePortMessage = (message: LpFilelessImporterMessage, port: chrome.runtime.Port) => { + const handler = this.portMessageHandlers[message.command]; + if (!handler) { + return; + } + + handler({ message, port }); + }; +} + +(function () { + if (!(globalThis as any).lpFilelessImporter) { + (globalThis as any).lpFilelessImporter = new LpFilelessImporter(); + (globalThis as any).lpFilelessImporter.init(); + } +})(); diff --git a/apps/browser/src/tools/enums/fileless-import.enums.ts b/apps/browser/src/tools/enums/fileless-import.enums.ts new file mode 100644 index 0000000000..e20f4f1545 --- /dev/null +++ b/apps/browser/src/tools/enums/fileless-import.enums.ts @@ -0,0 +1,12 @@ +const FilelessImportType = { + LP: "LP", +} as const; + +type FilelessImportTypeKeys = (typeof FilelessImportType)[keyof typeof FilelessImportType]; + +const FilelessImportPort = { + NotificationBar: "fileless-importer-notification-bar", + LpImporter: "lp-fileless-importer", +} as const; + +export { FilelessImportType, FilelessImportTypeKeys, FilelessImportPort }; diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index feb2d6ef51..86931cb54b 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -180,6 +180,7 @@ const mainConfig = { "overlay/button": "./src/autofill/overlay/pages/button/bootstrap-autofill-overlay-button.ts", "overlay/list": "./src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts", "encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts", + "content/lp-fileless-importer": "./src/tools/content/lp-fileless-importer.ts", }, optimization: { minimize: ENV !== "development",