1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-25 12:15:18 +01:00

[PM-5976] Safari Browser SSO Initialization Race Condition Attempted Fix 3 (#7800)

* [PM-5976] Safari Browser SSO Initialization Race Condition Attempted Fix 3

* [PM-5976] Safari Browser SSO Initialization Race Condition Attempted Fix 3

* [PM-5976] Removing usage of pinging system and keeping reworked top-level registration of window message listener events

* [PM-5976] Pulling the implementation of the static content script delcaration for the content-message-handler file to the top of the list of content_scripts

* [PM-5976] Pulling the implementation of the static content script delcaration for the content-message-handler file to the top of the list of content_scripts

* [PM-5976] Removing the useCapture value within the window message event listener
This commit is contained in:
Cesar Gonzalez 2024-02-05 09:23:17 -06:00 committed by GitHub
parent e847244817
commit 25711afaf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 174 additions and 171 deletions

View File

@ -1,6 +1,24 @@
interface ContentMessageHandler { type ContentMessageWindowData = {
init(): void; command: string;
destroy(): void; lastpass?: boolean;
} code?: string;
state?: string;
data?: string;
remember?: boolean;
};
type ContentMessageWindowEventParams = {
data: ContentMessageWindowData;
referrer: string;
};
export { ContentMessageHandler }; type ContentMessageWindowEventHandlers = {
[key: string]: ({ data, referrer }: ContentMessageWindowEventParams) => void;
authResult: ({ data, referrer }: ContentMessageWindowEventParams) => void;
webAuthnResult: ({ data, referrer }: ContentMessageWindowEventParams) => void;
};
export {
ContentMessageWindowData,
ContentMessageWindowEventParams,
ContentMessageWindowEventHandlers,
};

View File

@ -1,12 +0,0 @@
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);

View File

@ -1,37 +1,31 @@
import { mock } from "jest-mock-extended";
import { postWindowMessage, sendExtensionRuntimeMessage } from "../jest/testing-utils"; import { postWindowMessage, sendExtensionRuntimeMessage } from "../jest/testing-utils";
import ContentMessageHandler from "./content-message-handler";
describe("ContentMessageHandler", () => { describe("ContentMessageHandler", () => {
let contentMessageHandler: ContentMessageHandler;
const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage"); const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage");
let portOnDisconnectAddListenerCallback: CallableFunction;
chrome.runtime.connect = jest.fn(() =>
mock<chrome.runtime.Port>({
onDisconnect: {
addListener: jest.fn((callback) => {
portOnDisconnectAddListenerCallback = callback;
}),
removeListener: jest.fn(),
},
}),
);
beforeEach(() => { beforeEach(() => {
contentMessageHandler = new ContentMessageHandler(); require("./content-message-handler");
}); });
afterEach(() => { afterEach(() => {
jest.resetModules();
jest.clearAllMocks(); jest.clearAllMocks();
contentMessageHandler.destroy();
}); });
describe("init", () => { describe("handled window messages", () => {
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", () => { it("ignores messages from other sources", () => {
postWindowMessage({ command: "authResult" }, "https://localhost/", null); postWindowMessage({ command: "authResult" }, "https://localhost/", null);
@ -47,7 +41,6 @@ describe("ContentMessageHandler", () => {
it("sends an authResult message", () => { it("sends an authResult message", () => {
postWindowMessage({ command: "authResult", lastpass: true, code: "code", state: "state" }); postWindowMessage({ command: "authResult", lastpass: true, code: "code", state: "state" });
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy).toHaveBeenCalledWith({ expect(sendMessageSpy).toHaveBeenCalledWith({
command: "authResult", command: "authResult",
code: "code", code: "code",
@ -60,7 +53,6 @@ describe("ContentMessageHandler", () => {
it("sends a webAuthnResult message", () => { it("sends a webAuthnResult message", () => {
postWindowMessage({ command: "webAuthnResult", data: "data", remember: true }); postWindowMessage({ command: "webAuthnResult", data: "data", remember: true });
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy).toHaveBeenCalledWith({ expect(sendMessageSpy).toHaveBeenCalledWith({
command: "webAuthnResult", command: "webAuthnResult",
data: "data", data: "data",
@ -70,11 +62,7 @@ describe("ContentMessageHandler", () => {
}); });
}); });
describe("handleExtensionMessage", () => { describe("handled extension messages", () => {
beforeEach(() => {
contentMessageHandler.init();
});
it("ignores the message to the extension background if it is not present in the forwardCommands list", () => { it("ignores the message to the extension background if it is not present in the forwardCommands list", () => {
sendExtensionRuntimeMessage({ command: "someOtherCommand" }); sendExtensionRuntimeMessage({ command: "someOtherCommand" });
@ -88,4 +76,17 @@ describe("ContentMessageHandler", () => {
expect(sendMessageSpy).toHaveBeenCalledWith({ command: "bgUnlockPopoutOpened" }); expect(sendMessageSpy).toHaveBeenCalledWith({ command: "bgUnlockPopoutOpened" });
}); });
}); });
describe("extension disconnect action", () => {
it("removes the window message listener and the extension message listener", () => {
const removeEventListenerSpy = jest.spyOn(window, "removeEventListener");
portOnDisconnectAddListenerCallback(mock<chrome.runtime.Port>());
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1);
expect(removeEventListenerSpy).toHaveBeenCalledWith("message", expect.any(Function));
expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledTimes(1);
expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith(expect.any(Function));
});
});
}); });

View File

@ -1,84 +1,110 @@
import { ContentMessageHandler as ContentMessageHandlerInterface } from "./abstractions/content-message-handler"; import {
ContentMessageWindowData,
ContentMessageWindowEventHandlers,
} from "./abstractions/content-message-handler";
class ContentMessageHandler implements ContentMessageHandlerInterface { /**
private forwardCommands = [ * IMPORTANT: Safari seems to have a bug where it doesn't properly handle
"bgUnlockPopoutOpened", * window message events from content scripts when the listener these events
"addToLockedVaultPendingNotifications", * is registered within a class. This is why these listeners are registered
"unlockCompleted", * at the top level of this file.
"addedCipher", */
]; window.addEventListener("message", handleWindowMessageEvent, false);
chrome.runtime.onMessage.addListener(handleExtensionMessage);
setupExtensionDisconnectAction(() => {
window.removeEventListener("message", handleWindowMessageEvent);
chrome.runtime.onMessage.removeListener(handleExtensionMessage);
});
/** /**
* Initialize the content message handler. Sets up * Handlers for window messages from the content script.
* a window message listener and a chrome runtime */
* message listener. const windowMessageHandlers: ContentMessageWindowEventHandlers = {
*/ authResult: ({ data, referrer }: { data: any; referrer: string }) =>
init() { handleAuthResultMessage(data, referrer),
window.addEventListener("message", this.handleWindowMessage, false); webAuthnResult: ({ data, referrer }: { data: any; referrer: string }) =>
chrome.runtime.onMessage.addListener(this.handleExtensionMessage); handleWebAuthnResultMessage(data, referrer),
} };
/** /**
* Handle a message from the window. This implementation * Handles the auth result message from the window.
* specifically handles the authResult and webAuthnResult *
* commands. This facilitates single sign-on. * @param data - Data from the window message
* * @param referrer - The referrer of the window
* @param event - The message event. */
*/ async function handleAuthResultMessage(data: ContentMessageWindowData, referrer: string) {
private handleWindowMessage = (event: MessageEvent) => { const { command, lastpass, code, state } = data;
const { source, data } = event; await chrome.runtime.sendMessage({ command, code, state, lastpass, referrer });
if (source !== window || !data?.command) {
return;
}
const { command } = data;
const referrer = source.location.hostname;
if (command === "checkIfReadyForAuthResult") {
window.postMessage({ command: "readyToReceiveAuthResult" }, "*");
}
if (command === "authResult") {
const { lastpass, code, state } = data;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
chrome.runtime.sendMessage({ command, code, state, lastpass, referrer });
}
if (command === "webAuthnResult") {
const { remember } = data;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
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)) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
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; /**
* Handles the webauthn result message from the window.
*
* @param data - Data from the window message
* @param referrer - The referrer of the window
*/
async function handleWebAuthnResultMessage(data: ContentMessageWindowData, referrer: string) {
const { command, remember } = data;
await chrome.runtime.sendMessage({ command, data: data.data, remember, referrer });
}
/**
* Handles the window message event.
*
* @param event - The window message event
*/
function handleWindowMessageEvent(event: MessageEvent) {
const { source, data } = event;
if (source !== window || !data?.command) {
return;
}
const referrer = source.location.hostname;
const handler = windowMessageHandlers[data.command];
if (handler) {
handler({ data, referrer });
}
}
/**
* Commands to forward from this script to the extension background.
*/
const forwardCommands = new Set([
"bgUnlockPopoutOpened",
"addToLockedVaultPendingNotifications",
"unlockCompleted",
"addedCipher",
]);
/**
* Handles messages from the extension. Currently, this is
* used to forward messages from the background context to
* other scripts within the extension.
*
* @param message - The message from the extension
*/
async function handleExtensionMessage(message: any) {
if (forwardCommands.has(message.command)) {
await chrome.runtime.sendMessage(message);
}
}
/**
* Duplicate implementation of the same named method within `apps/browser/src/autofill/utils/index.ts`.
* This is done due to some strange observed compilation behavior present when importing the method from
* the utils file.
*
* TODO: Investigate why webpack tree shaking is not removing other methods when importing from the utils file.
* Possible cause can be seen below:
* @see https://stackoverflow.com/questions/71679366/webpack5-does-not-seem-to-tree-shake-unused-exports
*
* @param callback - Callback function to run when the extension disconnects
*/
function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => void) {
const port = chrome.runtime.connect({ name: "autofill-injected-script-port" });
const onDisconnectCallback = (disconnectedPort: chrome.runtime.Port) => {
callback(disconnectedPort);
port.onDisconnect.removeListener(onDisconnectCallback);
};
port.onDisconnect.addListener(onDisconnectCallback);
}

View File

@ -1,9 +1,7 @@
import { AutofillInit } from "./content/abstractions/autofill-init"; import { AutofillInit } from "./content/abstractions/autofill-init";
import ContentMessageHandler from "./content/content-message-handler";
declare global { declare global {
interface Window { interface Window {
bitwardenAutofillInit?: AutofillInit; bitwardenAutofillInit?: AutofillInit;
bitwardenContentMessageHandler?: ContentMessageHandler;
} }
} }

View File

@ -207,11 +207,11 @@ describe("AutofillService", () => {
}); });
}); });
it("injects the bootstrap-content-message-handler script if not injecting on page load", async () => { it("injects the content-message-handler script if not injecting on page load", async () => {
await autofillService.injectAutofillScripts(sender.tab, sender.frameId, false); await autofillService.injectAutofillScripts(sender.tab, sender.frameId, false);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: "content/bootstrap-content-message-handler.js", file: "content/content-message-handler.js",
...defaultExecuteScriptOptions, ...defaultExecuteScriptOptions,
}); });
}); });

View File

@ -105,7 +105,7 @@ export default class AutofillService implements AutofillServiceInterface {
injectedScripts.push("autofiller.js"); injectedScripts.push("autofiller.js");
} else { } else {
await BrowserApi.executeScriptInTab(tab.id, { await BrowserApi.executeScriptInTab(tab.id, {
file: "content/bootstrap-content-message-handler.js", file: "content/content-message-handler.js",
runAt: "document_start", runAt: "document_start",
}); });
} }

View File

@ -15,6 +15,12 @@
"128": "images/icon128.png" "128": "images/icon128.png"
}, },
"content_scripts": [ "content_scripts": [
{
"all_frames": false,
"js": ["content/content-message-handler.js"],
"matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start"
},
{ {
"all_frames": true, "all_frames": true,
"js": [ "js": [
@ -24,12 +30,6 @@
"matches": ["http://*/*", "https://*/*", "file:///*"], "matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start" "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, "all_frames": true,
"css": ["content/autofill.css"], "css": ["content/autofill.css"],

View File

@ -17,14 +17,14 @@
}, },
"content_scripts": [ "content_scripts": [
{ {
"all_frames": true, "all_frames": false,
"js": ["content/trigger-autofill-script-injection.js"], "js": ["content/content-message-handler.js"],
"matches": ["http://*/*", "https://*/*", "file:///*"], "matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start" "run_at": "document_start"
}, },
{ {
"all_frames": false, "all_frames": true,
"js": ["content/bootstrap-content-message-handler.js"], "js": ["content/trigger-autofill-script-injection.js"],
"matches": ["http://*/*", "https://*/*", "file:///*"], "matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start" "run_at": "document_start"
}, },

View File

@ -169,8 +169,7 @@ const mainConfig = {
"content/autofiller": "./src/autofill/content/autofiller.ts", "content/autofiller": "./src/autofill/content/autofiller.ts",
"content/notificationBar": "./src/autofill/content/notification-bar.ts", "content/notificationBar": "./src/autofill/content/notification-bar.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
"content/bootstrap-content-message-handler": "content/content-message-handler": "./src/autofill/content/content-message-handler.ts",
"./src/autofill/content/bootstrap-content-message-handler.ts",
"content/fido2/trigger-fido2-content-script-injection": "content/fido2/trigger-fido2-content-script-injection":
"./src/vault/fido2/content/trigger-fido2-content-script-injection.ts", "./src/vault/fido2/content/trigger-fido2-content-script-injection.ts",
"content/fido2/content-script": "./src/vault/fido2/content/content-script.ts", "content/fido2/content-script": "./src/vault/fido2/content/content-script.ts",

View File

@ -8,9 +8,9 @@ window.addEventListener("load", () => {
const lastpass = getQsParam("lp"); const lastpass = getQsParam("lp");
if (lastpass === "1") { if (lastpass === "1") {
initiateBrowserSsoIfDocumentReady(code, state, true); initiateBrowserSso(code, state, true);
} else if (state != null && state.includes(":clientId=browser")) { } else if (state != null && state.includes(":clientId=browser")) {
initiateBrowserSsoIfDocumentReady(code, state, false); initiateBrowserSso(code, state, false);
} else { } else {
window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state; window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state;
// Match any characters between "_returnUri='" and the next "'" // Match any characters between "_returnUri='" and the next "'"
@ -23,33 +23,6 @@ window.addEventListener("load", () => {
} }
}); });
function initiateBrowserSsoIfDocumentReady(code: string, state: string, lastpass: boolean) {
const MAX_ATTEMPTS = 200;
const TIMEOUT_MS = 50;
let attempts = 0;
const pingInterval = setInterval(() => {
if (attempts >= MAX_ATTEMPTS) {
clearInterval(pingInterval);
throw new Error("Failed to initiate browser SSO");
}
attempts++;
window.postMessage({ command: "checkIfReadyForAuthResult" }, "*");
}, TIMEOUT_MS);
const handleWindowMessage = (event: MessageEvent) => {
if (event.source === window && event.data?.command === "readyToReceiveAuthResult") {
clearInterval(pingInterval);
window.removeEventListener("message", handleWindowMessage);
initiateBrowserSso(code, state, lastpass);
}
};
window.addEventListener("message", handleWindowMessage);
}
function initiateBrowserSso(code: string, state: string, lastpass: boolean) { function initiateBrowserSso(code: string, state: string, lastpass: boolean) {
window.postMessage({ command: "authResult", code: code, state: state, lastpass: lastpass }, "*"); window.postMessage({ command: "authResult", code: code, state: state, lastpass: lastpass }, "*");
const handOffMessage = ("; " + document.cookie) const handOffMessage = ("; " + document.cookie)