diff --git a/apps/browser/src/autofill/content/abstractions/content-message-handler.ts b/apps/browser/src/autofill/content/abstractions/content-message-handler.ts new file mode 100644 index 0000000000..113503182c --- /dev/null +++ b/apps/browser/src/autofill/content/abstractions/content-message-handler.ts @@ -0,0 +1,6 @@ +interface ContentMessageHandler { + init(): void; + destroy(): void; +} + +export { ContentMessageHandler }; diff --git a/apps/browser/src/autofill/content/bootstrap-content-message-handler.ts b/apps/browser/src/autofill/content/bootstrap-content-message-handler.ts new file mode 100644 index 0000000000..f4044f7184 --- /dev/null +++ b/apps/browser/src/autofill/content/bootstrap-content-message-handler.ts @@ -0,0 +1,12 @@ +import { setupExtensionDisconnectAction } from "../utils"; + +import ContentMessageHandler from "./content-message-handler"; + +(function (windowContext) { + if (!windowContext.bitwardenContentMessageHandler) { + windowContext.bitwardenContentMessageHandler = new ContentMessageHandler(); + setupExtensionDisconnectAction(() => windowContext.bitwardenContentMessageHandler.destroy()); + + windowContext.bitwardenContentMessageHandler.init(); + } +})(window); diff --git a/apps/browser/src/autofill/content/content-message-handler.spec.ts b/apps/browser/src/autofill/content/content-message-handler.spec.ts new file mode 100644 index 0000000000..fbddcb74cf --- /dev/null +++ b/apps/browser/src/autofill/content/content-message-handler.spec.ts @@ -0,0 +1,91 @@ +import { postWindowMessage, sendExtensionRuntimeMessage } from "../jest/testing-utils"; + +import ContentMessageHandler from "./content-message-handler"; + +describe("ContentMessageHandler", () => { + let contentMessageHandler: ContentMessageHandler; + const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage"); + + beforeEach(() => { + contentMessageHandler = new ContentMessageHandler(); + }); + + afterEach(() => { + jest.clearAllMocks(); + contentMessageHandler.destroy(); + }); + + describe("init", () => { + it("should add event listeners", () => { + const addEventListenerSpy = jest.spyOn(window, "addEventListener"); + const addListenerSpy = jest.spyOn(chrome.runtime.onMessage, "addListener"); + + contentMessageHandler.init(); + + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(addListenerSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("handleWindowMessage", () => { + beforeEach(() => { + contentMessageHandler.init(); + }); + + it("ignores messages from other sources", () => { + postWindowMessage({ command: "authResult" }, "https://localhost/", null); + + expect(sendMessageSpy).not.toHaveBeenCalled(); + }); + + it("ignores messages without a command", () => { + postWindowMessage({}); + + expect(sendMessageSpy).not.toHaveBeenCalled(); + }); + + it("sends an authResult message", () => { + postWindowMessage({ command: "authResult", lastpass: true, code: "code", state: "state" }); + + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy).toHaveBeenCalledWith({ + command: "authResult", + code: "code", + state: "state", + lastpass: true, + referrer: "localhost", + }); + }); + + it("sends a webAuthnResult message", () => { + postWindowMessage({ command: "webAuthnResult", data: "data", remember: true }); + + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy).toHaveBeenCalledWith({ + command: "webAuthnResult", + data: "data", + remember: true, + referrer: "localhost", + }); + }); + }); + + describe("handleExtensionMessage", () => { + beforeEach(() => { + contentMessageHandler.init(); + }); + + it("ignores the message to the extension background if it is not present in the forwardCommands list", () => { + sendExtensionRuntimeMessage({ command: "someOtherCommand" }); + + expect(sendMessageSpy).not.toHaveBeenCalled(); + }); + + it("forwards the message to the extension background if it is present in the forwardCommands list", () => { + sendExtensionRuntimeMessage({ command: "bgUnlockPopoutOpened" }); + + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy).toHaveBeenCalledWith({ command: "bgUnlockPopoutOpened" }); + }); + }); +}); diff --git a/apps/browser/src/autofill/content/content-message-handler.ts b/apps/browser/src/autofill/content/content-message-handler.ts new file mode 100644 index 0000000000..a7252adbe8 --- /dev/null +++ b/apps/browser/src/autofill/content/content-message-handler.ts @@ -0,0 +1,74 @@ +import { ContentMessageHandler as ContentMessageHandlerInterface } from "./abstractions/content-message-handler"; + +class ContentMessageHandler implements ContentMessageHandlerInterface { + private forwardCommands = [ + "bgUnlockPopoutOpened", + "addToLockedVaultPendingNotifications", + "unlockCompleted", + "addedCipher", + ]; + + /** + * Initialize the content message handler. Sets up + * a window message listener and a chrome runtime + * message listener. + */ + init() { + window.addEventListener("message", this.handleWindowMessage, false); + chrome.runtime.onMessage.addListener(this.handleExtensionMessage); + } + + /** + * Handle a message from the window. This implementation + * specifically handles the authResult and webAuthnResult + * commands. This facilitates single sign-on. + * + * @param event - The message event. + */ + private handleWindowMessage = (event: MessageEvent) => { + const { source, data } = event; + + if (source !== window || !data?.command) { + return; + } + + const { command } = data; + const referrer = source.location.hostname; + + if (command === "authResult") { + const { lastpass, code, state } = data; + chrome.runtime.sendMessage({ command, code, state, lastpass, referrer }); + } + + if (command === "webAuthnResult") { + const { remember } = data; + chrome.runtime.sendMessage({ command, data: data.data, remember, referrer }); + } + }; + + /** + * Handle a message from the extension. This + * implementation forwards the message to the + * extension background so that it can be received + * in other contexts of the background script. + * + * @param message - The message from the extension. + */ + private handleExtensionMessage = (message: any) => { + if (this.forwardCommands.includes(message.command)) { + chrome.runtime.sendMessage(message); + } + }; + + /** + * Destroy the content message handler. Removes + * the window message listener and the chrome + * runtime message listener. + */ + destroy = () => { + window.removeEventListener("message", this.handleWindowMessage); + chrome.runtime.onMessage.removeListener(this.handleExtensionMessage); + }; +} + +export default ContentMessageHandler; diff --git a/apps/browser/src/autofill/content/message_handler.ts b/apps/browser/src/autofill/content/message_handler.ts deleted file mode 100644 index 7b52aeb355..0000000000 --- a/apps/browser/src/autofill/content/message_handler.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { setupExtensionDisconnectAction } from "../utils"; - -const forwardCommands = [ - "bgUnlockPopoutOpened", - "addToLockedVaultPendingNotifications", - "unlockCompleted", - "addedCipher", -]; - -/** - * 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/globals.d.ts b/apps/browser/src/autofill/globals.d.ts index dafd49e50c..6f10b9b4db 100644 --- a/apps/browser/src/autofill/globals.d.ts +++ b/apps/browser/src/autofill/globals.d.ts @@ -1,7 +1,9 @@ import { AutofillInit } from "./content/abstractions/autofill-init"; +import ContentMessageHandler from "./content/content-message-handler"; declare global { interface Window { bitwardenAutofillInit?: AutofillInit; + bitwardenContentMessageHandler?: ContentMessageHandler; } } diff --git a/apps/browser/src/autofill/jest/testing-utils.ts b/apps/browser/src/autofill/jest/testing-utils.ts index e0972e3d90..a2bd3d9091 100644 --- a/apps/browser/src/autofill/jest/testing-utils.ts +++ b/apps/browser/src/autofill/jest/testing-utils.ts @@ -11,8 +11,8 @@ function flushPromises() { }); } -function postWindowMessage(data: any, origin = "https://localhost/") { - globalThis.dispatchEvent(new MessageEvent("message", { data, origin })); +function postWindowMessage(data: any, origin = "https://localhost/", source = window) { + globalThis.dispatchEvent(new MessageEvent("message", { data, origin, source })); } function sendExtensionRuntimeMessage( diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 5f9c1db68c..7901fe63b7 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -246,6 +246,15 @@ describe("AutofillService", () => { ...defaultExecuteScriptOptions, }); }); + + it("injects the bootstrap-content-message-handler script if not injecting on page load", async () => { + await autofillService.injectAutofillScripts(sender.tab, sender.frameId, false); + + expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { + file: "content/bootstrap-content-message-handler.js", + ...defaultExecuteScriptOptions, + }); + }); }); describe("getFormsWithPasswordFields", () => { diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index eddc8f93a9..11c3bafad8 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -109,9 +109,16 @@ export default class AutofillService implements AutofillServiceInterface { } const injectedScripts = [mainAutofillScript]; + if (triggeringOnPageLoad) { injectedScripts.push("autofiller.js"); + } else { + await BrowserApi.executeScriptInTab(tab.id, { + file: "content/bootstrap-content-message-handler.js", + runAt: "document_start", + }); } + injectedScripts.push("notificationBar.js", "contextMenuHandler.js"); for (const injectedScript of injectedScripts) { @@ -121,11 +128,6 @@ export default class AutofillService implements AutofillServiceInterface { runAt: "document_start", }); } - - await BrowserApi.executeScriptInTab(tab.id, { - file: "content/message_handler.js", - runAt: "document_start", - }); } /** diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 7d569f0047..66f65a9850 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -24,6 +24,12 @@ "matches": ["http://*/*", "https://*/*", "file:///*"], "run_at": "document_start" }, + { + "all_frames": false, + "js": ["content/bootstrap-content-message-handler.js"], + "matches": ["http://*/*", "https://*/*", "file:///*"], + "run_at": "document_start" + }, { "all_frames": true, "css": ["content/autofill.css"], diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 5b04be53a3..39fda3291f 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -29,7 +29,7 @@ }, { "all_frames": false, - "js": ["content/message_handler.js"], + "js": ["content/bootstrap-content-message-handler.js"], "matches": ["http://*/*", "https://*/*", "file:///*"], "run_at": "document_start" }, diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index f5342f7492..feb2d6ef51 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -170,7 +170,8 @@ const mainConfig = { "content/autofiller": "./src/autofill/content/autofiller.ts", "content/notificationBar": "./src/autofill/content/notification-bar.ts", "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", - "content/message_handler": "./src/autofill/content/message_handler.ts", + "content/bootstrap-content-message-handler": + "./src/autofill/content/bootstrap-content-message-handler.ts", "content/fido2/trigger-fido2-content-script-injection": "./src/vault/fido2/content/trigger-fido2-content-script-injection.ts", "content/fido2/content-script": "./src/vault/fido2/content/content-script.ts",