1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-28 04:08:47 +02:00

[PM-934] Autofill not working until page has been refreshed (#6826)

* [PM-934] Autofill not working until page has been refreshed

* [PM-934] Adjusting cleanup of the messages_handler script

* [PM-934] Fixing small issue found within collection of page details

* [PM-934] Addressing concenrs brought up during code review

* [PM-934] Addressing concenrs brought up during code review

* [PM-934] Addressing concenrs brought up during code review

* [PM-934] Addressing concenrs brought up during code review

* [PM-934] Applying re-set changes to the autofill overlay implementation on reset of the extension

* [PM-934] Applying jest tests to added logic within AutofillOverlayContent service

* [PM-934] Fixing typo present in tabs background listener

* [PM-934] Finishing up jest tests for updated implementation

* [PM-934] Incorporating methodology for ensuring the autofill overlay updates to reflect user settings within existing tabs

* [PM-934] Refining implementation to ensure we do not unnecessarily re-inject content scripts when the autofill overlay settings change

* [PM-934] Working through jest tests for added implementation details

* [PM-934] Working through jest tests for added implementation details

* [PM-934] Finalizing jest tests for implemented logic

* [PM-5035] Refactoring method structure
This commit is contained in:
Cesar Gonzalez 2023-12-13 10:25:16 -06:00 committed by GitHub
parent 7051f255ed
commit bf60711efe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 836 additions and 128 deletions

View File

@ -673,7 +673,7 @@ class OverlayBackground implements OverlayBackgroundInterface {
*/ */
private setupExtensionMessageListeners() { private setupExtensionMessageListeners() {
BrowserApi.messageListener("overlay.background", this.handleExtensionMessage); BrowserApi.messageListener("overlay.background", this.handleExtensionMessage);
chrome.runtime.onConnect.addListener(this.handlePortOnConnect); BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect);
} }
/** /**

View File

@ -10,6 +10,10 @@ import {
settingsServiceFactory, settingsServiceFactory,
SettingsServiceInitOptions, SettingsServiceInitOptions,
} from "../../../background/service-factories/settings-service.factory"; } from "../../../background/service-factories/settings-service.factory";
import {
configServiceFactory,
ConfigServiceInitOptions,
} from "../../../platform/background/service-factories/config-service.factory";
import { import {
CachedServices, CachedServices,
factory, factory,
@ -43,7 +47,8 @@ export type AutoFillServiceInitOptions = AutoFillServiceOptions &
EventCollectionServiceInitOptions & EventCollectionServiceInitOptions &
LogServiceInitOptions & LogServiceInitOptions &
SettingsServiceInitOptions & SettingsServiceInitOptions &
UserVerificationServiceInitOptions; UserVerificationServiceInitOptions &
ConfigServiceInitOptions;
export function autofillServiceFactory( export function autofillServiceFactory(
cache: { autofillService?: AbstractAutoFillService } & CachedServices, cache: { autofillService?: AbstractAutoFillService } & CachedServices,
@ -62,6 +67,7 @@ export function autofillServiceFactory(
await logServiceFactory(cache, opts), await logServiceFactory(cache, opts),
await settingsServiceFactory(cache, opts), await settingsServiceFactory(cache, opts),
await userVerificationServiceFactory(cache, opts), await userVerificationServiceFactory(cache, opts),
await configServiceFactory(cache, opts),
), ),
); );
} }

View File

@ -15,7 +15,7 @@ import OverlayBackground from "./overlay.background";
import TabsBackground from "./tabs.background"; import TabsBackground from "./tabs.background";
describe("TabsBackground", () => { describe("TabsBackground", () => {
let tabsBackgorund: TabsBackground; let tabsBackground: TabsBackground;
const mainBackground = mock<MainBackground>({ const mainBackground = mock<MainBackground>({
messagingService: { messagingService: {
send: jest.fn(), send: jest.fn(),
@ -25,7 +25,7 @@ describe("TabsBackground", () => {
const overlayBackground = mock<OverlayBackground>(); const overlayBackground = mock<OverlayBackground>();
beforeEach(() => { beforeEach(() => {
tabsBackgorund = new TabsBackground(mainBackground, notificationBackground, overlayBackground); tabsBackground = new TabsBackground(mainBackground, notificationBackground, overlayBackground);
}); });
afterEach(() => { afterEach(() => {
@ -35,11 +35,11 @@ describe("TabsBackground", () => {
describe("init", () => { describe("init", () => {
it("sets up a window on focusChanged listener", () => { it("sets up a window on focusChanged listener", () => {
const handleWindowOnFocusChangedSpy = jest.spyOn( const handleWindowOnFocusChangedSpy = jest.spyOn(
tabsBackgorund as any, tabsBackground as any,
"handleWindowOnFocusChanged", "handleWindowOnFocusChanged",
); );
tabsBackgorund.init(); tabsBackground.init();
expect(chrome.windows.onFocusChanged.addListener).toHaveBeenCalledWith( expect(chrome.windows.onFocusChanged.addListener).toHaveBeenCalledWith(
handleWindowOnFocusChangedSpy, handleWindowOnFocusChangedSpy,
@ -49,7 +49,7 @@ describe("TabsBackground", () => {
describe("tab event listeners", () => { describe("tab event listeners", () => {
beforeEach(() => { beforeEach(() => {
tabsBackgorund.init(); tabsBackground["setupTabEventListeners"]();
}); });
describe("window onFocusChanged event", () => { describe("window onFocusChanged event", () => {
@ -64,7 +64,7 @@ describe("TabsBackground", () => {
triggerWindowOnFocusedChangedEvent(10); triggerWindowOnFocusedChangedEvent(10);
await flushPromises(); await flushPromises();
expect(tabsBackgorund["focusedWindowId"]).toBe(10); expect(tabsBackground["focusedWindowId"]).toBe(10);
}); });
it("updates the current tab data", async () => { it("updates the current tab data", async () => {
@ -144,7 +144,7 @@ describe("TabsBackground", () => {
beforeEach(() => { beforeEach(() => {
mainBackground.onUpdatedRan = false; mainBackground.onUpdatedRan = false;
tabsBackgorund["focusedWindowId"] = focusedWindowId; tabsBackground["focusedWindowId"] = focusedWindowId;
tab = mock<chrome.tabs.Tab>({ tab = mock<chrome.tabs.Tab>({
windowId: focusedWindowId, windowId: focusedWindowId,
active: true, active: true,

View File

@ -20,6 +20,14 @@ export default class TabsBackground {
return; return;
} }
this.updateCurrentTabData();
this.setupTabEventListeners();
}
/**
* Sets up the tab and window event listeners.
*/
private setupTabEventListeners() {
chrome.windows.onFocusChanged.addListener(this.handleWindowOnFocusChanged); chrome.windows.onFocusChanged.addListener(this.handleWindowOnFocusChanged);
chrome.tabs.onActivated.addListener(this.handleTabOnActivated); chrome.tabs.onActivated.addListener(this.handleTabOnActivated);
chrome.tabs.onReplaced.addListener(this.handleTabOnReplaced); chrome.tabs.onReplaced.addListener(this.handleTabOnReplaced);
@ -33,7 +41,7 @@ export default class TabsBackground {
* @param windowId - The ID of the window that was focused. * @param windowId - The ID of the window that was focused.
*/ */
private handleWindowOnFocusChanged = async (windowId: number) => { private handleWindowOnFocusChanged = async (windowId: number) => {
if (!windowId) { if (windowId == null || windowId < 0) {
return; return;
} }
@ -116,8 +124,10 @@ export default class TabsBackground {
* for the current tab. Also updates the overlay ciphers. * for the current tab. Also updates the overlay ciphers.
*/ */
private updateCurrentTabData = async () => { private updateCurrentTabData = async () => {
await this.main.refreshBadge(); await Promise.all([
await this.main.refreshMenu(); this.main.refreshBadge(),
await this.overlayBackground.updateOverlayCiphers(); this.main.refreshMenu(),
this.overlayBackground.updateOverlayCiphers(),
]);
}; };
} }

View File

@ -17,6 +17,7 @@ type AutofillExtensionMessage = {
direction?: "previous" | "next"; direction?: "previous" | "next";
isOpeningFullOverlay?: boolean; isOpeningFullOverlay?: boolean;
forceCloseOverlay?: boolean; forceCloseOverlay?: boolean;
autofillOverlayVisibility?: number;
}; };
}; };
@ -34,10 +35,12 @@ type AutofillExtensionMessageHandlers = {
updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void; updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void;
bgUnlockPopoutOpened: () => void; bgUnlockPopoutOpened: () => void;
bgVaultItemRepromptPopoutOpened: () => void; bgVaultItemRepromptPopoutOpened: () => void;
updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void;
}; };
interface AutofillInit { interface AutofillInit {
init(): void; init(): void;
destroy(): void;
} }
export { AutofillExtensionMessage, AutofillExtensionMessageHandlers, AutofillInit }; export { AutofillExtensionMessage, AutofillExtensionMessageHandlers, AutofillInit };

View File

@ -6,7 +6,7 @@ import { flushPromises, sendExtensionRuntimeMessage } from "../jest/testing-util
import AutofillPageDetails from "../models/autofill-page-details"; import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script"; import AutofillScript from "../models/autofill-script";
import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
import { RedirectFocusDirection } from "../utils/autofill-overlay.enum"; import { AutofillOverlayVisibility, RedirectFocusDirection } from "../utils/autofill-overlay.enum";
import { AutofillExtensionMessage } from "./abstractions/autofill-init"; import { AutofillExtensionMessage } from "./abstractions/autofill-init";
import AutofillInit from "./autofill-init"; import AutofillInit from "./autofill-init";
@ -16,6 +16,11 @@ describe("AutofillInit", () => {
const autofillOverlayContentService = mock<AutofillOverlayContentService>(); const autofillOverlayContentService = mock<AutofillOverlayContentService>();
beforeEach(() => { beforeEach(() => {
chrome.runtime.connect = jest.fn().mockReturnValue({
onDisconnect: {
addListener: jest.fn(),
},
});
autofillInit = new AutofillInit(autofillOverlayContentService); autofillInit = new AutofillInit(autofillOverlayContentService);
}); });
@ -477,6 +482,57 @@ describe("AutofillInit", () => {
expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled();
}); });
}); });
describe("updateAutofillOverlayVisibility", () => {
beforeEach(() => {
autofillInit["autofillOverlayContentService"].autofillOverlayVisibility =
AutofillOverlayVisibility.OnButtonClick;
});
it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => {
sendExtensionRuntimeMessage({
command: "updateAutofillOverlayVisibility",
data: {},
});
expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual(
AutofillOverlayVisibility.OnButtonClick,
);
});
it("updates the overlay visibility value", () => {
const message = {
command: "updateAutofillOverlayVisibility",
data: {
autofillOverlayVisibility: AutofillOverlayVisibility.Off,
},
};
sendExtensionRuntimeMessage(message);
expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual(
message.data.autofillOverlayVisibility,
);
});
});
});
});
describe("destroy", () => {
it("removes the extension message listeners", () => {
autofillInit.destroy();
expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith(
autofillInit["handleExtensionMessage"],
);
});
it("destroys the collectAutofillContentService", () => {
jest.spyOn(autofillInit["collectAutofillContentService"], "destroy");
autofillInit.destroy();
expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled();
}); });
}); });
}); });

View File

@ -26,6 +26,7 @@ class AutofillInit implements AutofillInitInterface {
updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message), updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message),
bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(), bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(),
bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(), bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(),
updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message),
}; };
/** /**
@ -214,6 +215,19 @@ class AutofillInit implements AutofillInitInterface {
); );
} }
/**
* Updates the autofill overlay visibility.
*
* @param data - Contains the autoFillOverlayVisibility value
*/
private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) {
return;
}
this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility;
}
/** /**
* Sets up the extension message listeners for the content script. * Sets up the extension message listeners for the content script.
*/ */
@ -247,6 +261,16 @@ class AutofillInit implements AutofillInitInterface {
Promise.resolve(messageResponse).then((response) => sendResponse(response)); Promise.resolve(messageResponse).then((response) => sendResponse(response));
return true; return true;
}; };
/**
* Handles destroying the autofill init content script. Removes all
* listeners, timeouts, and object instances to prevent memory leaks.
*/
destroy() {
chrome.runtime.onMessage.removeListener(this.handleExtensionMessage);
this.collectAutofillContentService.destroy();
this.autofillOverlayContentService?.destroy();
}
} }
export default AutofillInit; export default AutofillInit;

View File

@ -1,3 +1,5 @@
import { getFromLocalStorage, setupExtensionDisconnectAction } from "../utils";
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", loadAutofiller); document.addEventListener("DOMContentLoaded", loadAutofiller);
} else { } else {
@ -8,27 +10,30 @@ function loadAutofiller() {
let pageHref: string = null; let pageHref: string = null;
let filledThisHref = false; let filledThisHref = false;
let delayFillTimeout: number; let delayFillTimeout: number;
let doFillInterval: NodeJS.Timeout;
const activeUserIdKey = "activeUserId"; const handleExtensionDisconnect = () => {
let activeUserId: string; clearDoFillInterval();
clearDelayFillTimeout();
chrome.storage.local.get(activeUserIdKey, (obj: any) => { };
if (obj == null || obj[activeUserIdKey] == null) { const handleExtensionMessage = (message: any) => {
return; if (message.command === "fillForm" && pageHref === message.url) {
}
activeUserId = obj[activeUserIdKey];
});
chrome.storage.local.get(activeUserId, (obj: any) => {
if (obj?.[activeUserId]?.settings?.enableAutoFillOnPageLoad === true) {
setInterval(() => doFillIfNeeded(), 500);
}
});
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.command === "fillForm" && pageHref === msg.url) {
filledThisHref = true; filledThisHref = true;
} }
}); };
setupExtensionEventListeners();
triggerUserFillOnLoad();
async function triggerUserFillOnLoad() {
const activeUserIdKey = "activeUserId";
const userKeyStorage = await getFromLocalStorage(activeUserIdKey);
const activeUserId = userKeyStorage[activeUserIdKey];
const activeUserStorage = await getFromLocalStorage(activeUserId);
if (activeUserStorage?.[activeUserId]?.settings?.enableAutoFillOnPageLoad === true) {
clearDoFillInterval();
doFillInterval = setInterval(() => doFillIfNeeded(), 500);
}
}
function doFillIfNeeded(force = false) { function doFillIfNeeded(force = false) {
if (force || pageHref !== window.location.href) { if (force || pageHref !== window.location.href) {
@ -36,9 +41,7 @@ function loadAutofiller() {
// Some websites are slow and rendering all page content. Try to fill again later // Some websites are slow and rendering all page content. Try to fill again later
// if we haven't already. // if we haven't already.
filledThisHref = false; filledThisHref = false;
if (delayFillTimeout != null) { clearDelayFillTimeout();
window.clearTimeout(delayFillTimeout);
}
delayFillTimeout = window.setTimeout(() => { delayFillTimeout = window.setTimeout(() => {
if (!filledThisHref) { if (!filledThisHref) {
doFillIfNeeded(true); doFillIfNeeded(true);
@ -55,4 +58,21 @@ function loadAutofiller() {
chrome.runtime.sendMessage(msg); chrome.runtime.sendMessage(msg);
} }
} }
function clearDoFillInterval() {
if (doFillInterval) {
window.clearInterval(doFillInterval);
}
}
function clearDelayFillTimeout() {
if (delayFillTimeout) {
window.clearTimeout(delayFillTimeout);
}
}
function setupExtensionEventListeners() {
setupExtensionDisconnectAction(handleExtensionDisconnect);
chrome.runtime.onMessage.addListener(handleExtensionMessage);
}
} }

View File

@ -1,4 +1,5 @@
import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
import { setupAutofillInitDisconnectAction } from "../utils";
import AutofillInit from "./autofill-init"; import AutofillInit from "./autofill-init";
@ -6,6 +7,8 @@ import AutofillInit from "./autofill-init";
if (!windowContext.bitwardenAutofillInit) { if (!windowContext.bitwardenAutofillInit) {
const autofillOverlayContentService = new AutofillOverlayContentService(); const autofillOverlayContentService = new AutofillOverlayContentService();
windowContext.bitwardenAutofillInit = new AutofillInit(autofillOverlayContentService); windowContext.bitwardenAutofillInit = new AutofillInit(autofillOverlayContentService);
setupAutofillInitDisconnectAction(windowContext);
windowContext.bitwardenAutofillInit.init(); windowContext.bitwardenAutofillInit.init();
} }
})(window); })(window);

View File

@ -1,8 +1,12 @@
import { setupAutofillInitDisconnectAction } from "../utils";
import AutofillInit from "./autofill-init"; import AutofillInit from "./autofill-init";
(function (windowContext) { (function (windowContext) {
if (!windowContext.bitwardenAutofillInit) { if (!windowContext.bitwardenAutofillInit) {
windowContext.bitwardenAutofillInit = new AutofillInit(); windowContext.bitwardenAutofillInit = new AutofillInit();
setupAutofillInitDisconnectAction(windowContext);
windowContext.bitwardenAutofillInit.init(); windowContext.bitwardenAutofillInit.init();
} }
})(window); })(window);

View File

@ -1,31 +1,4 @@
window.addEventListener( import { setupExtensionDisconnectAction } from "../utils";
"message",
(event) => {
if (event.source !== window) {
return;
}
if (event.data.command && event.data.command === "authResult") {
chrome.runtime.sendMessage({
command: event.data.command,
code: event.data.code,
state: event.data.state,
lastpass: event.data.lastpass,
referrer: event.source.location.hostname,
});
}
if (event.data.command && event.data.command === "webAuthnResult") {
chrome.runtime.sendMessage({
command: event.data.command,
data: event.data.data,
remember: event.data.remember,
referrer: event.source.location.hostname,
});
}
},
false,
);
const forwardCommands = [ const forwardCommands = [
"bgUnlockPopoutOpened", "bgUnlockPopoutOpened",
@ -34,8 +7,59 @@ const forwardCommands = [
"addedCipher", "addedCipher",
]; ];
chrome.runtime.onMessage.addListener((event) => { /**
if (forwardCommands.includes(event.command)) { * Handles sending extension messages to the background
chrome.runtime.sendMessage(event); * 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);

View File

@ -4,6 +4,7 @@ import AddLoginRuntimeMessage from "../notification/models/add-login-runtime-mes
import ChangePasswordRuntimeMessage from "../notification/models/change-password-runtime-message"; import ChangePasswordRuntimeMessage from "../notification/models/change-password-runtime-message";
import { FormData } from "../services/abstractions/autofill.service"; import { FormData } from "../services/abstractions/autofill.service";
import { GlobalSettings, UserSettings } from "../types"; import { GlobalSettings, UserSettings } from "../types";
import { getFromLocalStorage, setupExtensionDisconnectAction } from "../utils";
interface HTMLElementWithFormOpId extends HTMLElement { interface HTMLElementWithFormOpId extends HTMLElement {
formOpId: string; formOpId: string;
@ -122,6 +123,8 @@ async function loadNotificationBar() {
} }
} }
setupExtensionDisconnectAction(handleExtensionDisconnection);
if (!showNotificationBar) { if (!showNotificationBar) {
return; return;
} }
@ -999,11 +1002,23 @@ async function loadNotificationBar() {
return theEl === document; return theEl === document;
} }
function handleExtensionDisconnection(port: chrome.runtime.Port) {
closeBar(false);
clearTimeout(domObservationCollectTimeoutId);
clearTimeout(collectPageDetailsTimeoutId);
clearTimeout(handlePageChangeTimeoutId);
observer?.disconnect();
observer = null;
watchedForms.forEach((wf: WatchedForm) => {
const form = wf.formEl;
form.removeEventListener("submit", formSubmitted, false);
const submitButton = getSubmitButton(
form,
unionSets(logInButtonNames, changePasswordButtonNames),
);
submitButton?.removeEventListener("click", formSubmitted, false);
});
}
// End Helper Functions // End Helper Functions
} }
async function getFromLocalStorage(keys: string | string[]): Promise<Record<string, any>> {
return new Promise((resolve) => {
chrome.storage.local.get(keys, (storage: Record<string, any>) => resolve(storage));
});
}

View File

@ -0,0 +1,5 @@
const AutofillPort = {
InjectedScript: "autofill-injected-script-port",
} as const;
export { AutofillPort };

View File

@ -1,5 +1,5 @@
import { EVENTS } from "../../constants"; import { EVENTS } from "../../constants";
import { setElementStyles } from "../../utils/utils"; import { setElementStyles } from "../../utils";
import { import {
BackgroundPortMessageHandlers, BackgroundPortMessageHandlers,
AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface, AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface,
@ -166,9 +166,10 @@ class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterf
this.updateElementStyles(this.iframe, { opacity: "0", height: "0px", display: "block" }); this.updateElementStyles(this.iframe, { opacity: "0", height: "0px", display: "block" });
globalThis.removeEventListener("message", this.handleWindowMessage); globalThis.removeEventListener("message", this.handleWindowMessage);
this.port.onMessage.removeListener(this.handlePortMessage); this.unobserveIframe();
this.port.onDisconnect.removeListener(this.handlePortDisconnect); this.port?.onMessage.removeListener(this.handlePortMessage);
this.port.disconnect(); this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
this.port?.disconnect();
this.port = null; this.port = null;
}; };
@ -369,7 +370,7 @@ class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterf
* Unobserves the iframe element for mutations to its style attribute. * Unobserves the iframe element for mutations to its style attribute.
*/ */
private unobserveIframe() { private unobserveIframe() {
this.iframeMutationObserver.disconnect(); this.iframeMutationObserver?.disconnect();
} }
/** /**

View File

@ -3,8 +3,8 @@ import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EVENTS } from "../../../constants"; import { EVENTS } from "../../../constants";
import { buildSvgDomElement } from "../../../utils";
import { logoIcon, logoLockedIcon } from "../../../utils/svg-icons"; import { logoIcon, logoLockedIcon } from "../../../utils/svg-icons";
import { buildSvgDomElement } from "../../../utils/utils";
import { import {
InitAutofillOverlayButtonMessage, InitAutofillOverlayButtonMessage,
OverlayButtonWindowMessageHandlers, OverlayButtonWindowMessageHandlers,

View File

@ -4,8 +4,8 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
import { OverlayCipherData } from "../../../background/abstractions/overlay.background"; import { OverlayCipherData } from "../../../background/abstractions/overlay.background";
import { EVENTS } from "../../../constants"; import { EVENTS } from "../../../constants";
import { buildSvgDomElement } from "../../../utils";
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../utils/svg-icons"; import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../utils/svg-icons";
import { buildSvgDomElement } from "../../../utils/utils";
import { import {
InitAutofillOverlayListMessage, InitAutofillOverlayListMessage,
OverlayListWindowMessageHandlers, OverlayListWindowMessageHandlers,

View File

@ -10,6 +10,7 @@ import { UriMatchType } from "@bitwarden/common/vault/enums";
import { BrowserApi } from "../../../platform/browser/browser-api"; import { BrowserApi } from "../../../platform/browser/browser-api";
import { flagEnabled } from "../../../platform/flags"; import { flagEnabled } from "../../../platform/flags";
import { AutofillService } from "../../services/abstractions/autofill.service";
import { AutofillOverlayVisibility } from "../../utils/autofill-overlay.enum"; import { AutofillOverlayVisibility } from "../../utils/autofill-overlay.enum";
@Component({ @Component({
@ -35,6 +36,7 @@ export class AutofillComponent implements OnInit {
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private configService: ConfigServiceAbstraction, private configService: ConfigServiceAbstraction,
private settingsService: SettingsService, private settingsService: SettingsService,
private autofillService: AutofillService,
) { ) {
this.autoFillOverlayVisibilityOptions = [ this.autoFillOverlayVisibilityOptions = [
{ {
@ -86,7 +88,10 @@ export class AutofillComponent implements OnInit {
} }
async updateAutoFillOverlayVisibility() { async updateAutoFillOverlayVisibility() {
const previousAutoFillOverlayVisibility =
await this.settingsService.getAutoFillOverlayVisibility();
await this.settingsService.setAutoFillOverlayVisibility(this.autoFillOverlayVisibility); await this.settingsService.setAutoFillOverlayVisibility(this.autoFillOverlayVisibility);
await this.handleUpdatingAutofillOverlayContentScripts(previousAutoFillOverlayVisibility);
} }
async updateAutoFillOnPageLoad() { async updateAutoFillOnPageLoad() {
@ -144,4 +149,25 @@ export class AutofillComponent implements OnInit {
event.preventDefault(); event.preventDefault();
BrowserApi.createNewTab(this.disablePasswordManagerLink); BrowserApi.createNewTab(this.disablePasswordManagerLink);
} }
private async handleUpdatingAutofillOverlayContentScripts(
previousAutoFillOverlayVisibility: number,
) {
const autofillOverlayPreviouslyDisabled =
previousAutoFillOverlayVisibility === AutofillOverlayVisibility.Off;
const autofillOverlayCurrentlyDisabled =
this.autoFillOverlayVisibility === AutofillOverlayVisibility.Off;
if (!autofillOverlayPreviouslyDisabled && !autofillOverlayCurrentlyDisabled) {
const tabs = await BrowserApi.tabsQuery({});
tabs.forEach((tab) =>
BrowserApi.tabSendMessageData(tab, "updateAutofillOverlayVisibility", {
autofillOverlayVisibility: this.autoFillOverlayVisibility,
}),
);
return;
}
await this.autofillService.reloadAutofillScripts();
}
} }

View File

@ -14,6 +14,7 @@ interface AutofillOverlayContentService {
isCurrentlyFilling: boolean; isCurrentlyFilling: boolean;
isOverlayCiphersPopulated: boolean; isOverlayCiphersPopulated: boolean;
pageDetailsUpdateRequired: boolean; pageDetailsUpdateRequired: boolean;
autofillOverlayVisibility: number;
init(): void; init(): void;
setupAutofillOverlayListenerOnField( setupAutofillOverlayListenerOnField(
autofillFieldElement: ElementWithOpId<FormFieldElement>, autofillFieldElement: ElementWithOpId<FormFieldElement>,
@ -27,6 +28,7 @@ interface AutofillOverlayContentService {
redirectOverlayFocusOut(direction: "previous" | "next"): void; redirectOverlayFocusOut(direction: "previous" | "next"): void;
focusMostRecentOverlayField(): void; focusMostRecentOverlayField(): void;
blurMostRecentOverlayField(): void; blurMostRecentOverlayField(): void;
destroy(): void;
} }
export { OpenAutofillOverlayOptions, AutofillOverlayContentService }; export { OpenAutofillOverlayOptions, AutofillOverlayContentService };

View File

@ -44,10 +44,12 @@ export interface GenerateFillScriptOptions {
} }
export abstract class AutofillService { export abstract class AutofillService {
loadAutofillScriptsOnInstall: () => Promise<void>;
reloadAutofillScripts: () => Promise<void>;
injectAutofillScripts: ( injectAutofillScripts: (
sender: chrome.runtime.MessageSender, tab: chrome.tabs.Tab,
autofillV2?: boolean, frameId?: number,
autofillOverlay?: boolean, triggeringOnPageLoad?: boolean,
) => Promise<void>; ) => Promise<void>;
getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[]; getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[];
doAutoFill: (options: AutoFillOptions) => Promise<string | null>; doAutoFill: (options: AutoFillOptions) => Promise<string | null>;

View File

@ -22,6 +22,7 @@ interface CollectAutofillContentService {
filterCallback: CallableFunction, filterCallback: CallableFunction,
isObservingShadowRoot?: boolean, isObservingShadowRoot?: boolean,
): Node[]; ): Node[];
destroy(): void;
} }
export { export {

View File

@ -1609,4 +1609,100 @@ describe("AutofillOverlayContentService", () => {
expect(autofillOverlayContentService["removeAutofillOverlay"]).toHaveBeenCalled(); expect(autofillOverlayContentService["removeAutofillOverlay"]).toHaveBeenCalled();
}); });
}); });
describe("destroy", () => {
let autofillFieldElement: ElementWithOpId<FormFieldElement>;
let autofillFieldData: AutofillField;
beforeEach(() => {
document.body.innerHTML = `
<form id="validFormId">
<input type="text" id="username-field" placeholder="username" />
<input type="password" id="password-field" placeholder="password" />
</form>
`;
autofillFieldElement = document.getElementById(
"username-field",
) as ElementWithOpId<FormFieldElement>;
autofillFieldElement.opid = "op-1";
autofillFieldData = createAutofillFieldMock({
opid: "username-field",
form: "validFormId",
placeholder: "username",
elementNumber: 1,
});
autofillOverlayContentService.setupAutofillOverlayListenerOnField(
autofillFieldElement,
autofillFieldData,
);
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
});
it("disconnects all mutation observers", () => {
autofillOverlayContentService["setupMutationObserver"]();
jest.spyOn(autofillOverlayContentService["bodyElementMutationObserver"], "disconnect");
jest.spyOn(autofillOverlayContentService["documentElementMutationObserver"], "disconnect");
autofillOverlayContentService.destroy();
expect(
autofillOverlayContentService["documentElementMutationObserver"].disconnect,
).toHaveBeenCalled();
expect(
autofillOverlayContentService["bodyElementMutationObserver"].disconnect,
).toHaveBeenCalled();
});
it("clears the user interaction event timeout", () => {
jest.spyOn(autofillOverlayContentService as any, "clearUserInteractionEventTimeout");
autofillOverlayContentService.destroy();
expect(autofillOverlayContentService["clearUserInteractionEventTimeout"]).toHaveBeenCalled();
});
it("de-registers all global event listeners", () => {
jest.spyOn(globalThis.document, "removeEventListener");
jest.spyOn(globalThis, "removeEventListener");
jest.spyOn(autofillOverlayContentService as any, "removeOverlayRepositionEventListeners");
autofillOverlayContentService.destroy();
expect(globalThis.document.removeEventListener).toHaveBeenCalledWith(
EVENTS.VISIBILITYCHANGE,
autofillOverlayContentService["handleVisibilityChangeEvent"],
);
expect(globalThis.removeEventListener).toHaveBeenCalledWith(
EVENTS.FOCUSOUT,
autofillOverlayContentService["handleFormFieldBlurEvent"],
);
expect(
autofillOverlayContentService["removeOverlayRepositionEventListeners"],
).toHaveBeenCalled();
});
it("de-registers any event listeners that are attached to the form field elements", () => {
jest.spyOn(autofillOverlayContentService as any, "removeCachedFormFieldEventListeners");
jest.spyOn(autofillFieldElement, "removeEventListener");
jest.spyOn(autofillOverlayContentService["formFieldElements"], "delete");
autofillOverlayContentService.destroy();
expect(
autofillOverlayContentService["removeCachedFormFieldEventListeners"],
).toHaveBeenCalledWith(autofillFieldElement);
expect(autofillFieldElement.removeEventListener).toHaveBeenCalledWith(
EVENTS.BLUR,
autofillOverlayContentService["handleFormFieldBlurEvent"],
);
expect(autofillFieldElement.removeEventListener).toHaveBeenCalledWith(
EVENTS.KEYUP,
autofillOverlayContentService["handleFormFieldKeyupEvent"],
);
expect(autofillOverlayContentService["formFieldElements"].delete).toHaveBeenCalledWith(
autofillFieldElement,
);
});
});
}); });

View File

@ -10,16 +10,12 @@ import AutofillField from "../models/autofill-field";
import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe"; import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe";
import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe"; import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe";
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
import { generateRandomCustomElementName, sendExtensionMessage, setElementStyles } from "../utils";
import { import {
AutofillOverlayElement, AutofillOverlayElement,
RedirectFocusDirection, RedirectFocusDirection,
AutofillOverlayVisibility, AutofillOverlayVisibility,
} from "../utils/autofill-overlay.enum"; } from "../utils/autofill-overlay.enum";
import {
generateRandomCustomElementName,
sendExtensionMessage,
setElementStyles,
} from "../utils/utils";
import { import {
AutofillOverlayContentService as AutofillOverlayContentServiceInterface, AutofillOverlayContentService as AutofillOverlayContentServiceInterface,
@ -32,9 +28,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
isCurrentlyFilling = false; isCurrentlyFilling = false;
isOverlayCiphersPopulated = false; isOverlayCiphersPopulated = false;
pageDetailsUpdateRequired = false; pageDetailsUpdateRequired = false;
autofillOverlayVisibility: number;
private readonly findTabs = tabbable; private readonly findTabs = tabbable;
private readonly sendExtensionMessage = sendExtensionMessage; private readonly sendExtensionMessage = sendExtensionMessage;
private autofillOverlayVisibility: number; private formFieldElements: Set<ElementWithOpId<FormFieldElement>> = new Set([]);
private userFilledFields: Record<string, FillableFormFieldElement> = {}; private userFilledFields: Record<string, FillableFormFieldElement> = {};
private authStatus: AuthenticationStatus; private authStatus: AuthenticationStatus;
private focusableElements: FocusableElement[] = []; private focusableElements: FocusableElement[] = [];
@ -47,6 +44,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
private userInteractionEventTimeout: NodeJS.Timeout; private userInteractionEventTimeout: NodeJS.Timeout;
private overlayElementsMutationObserver: MutationObserver; private overlayElementsMutationObserver: MutationObserver;
private bodyElementMutationObserver: MutationObserver; private bodyElementMutationObserver: MutationObserver;
private documentElementMutationObserver: MutationObserver;
private mutationObserverIterations = 0; private mutationObserverIterations = 0;
private mutationObserverIterationsResetTimeout: NodeJS.Timeout; private mutationObserverIterationsResetTimeout: NodeJS.Timeout;
private autofillFieldKeywordsMap: WeakMap<AutofillField, string> = new WeakMap(); private autofillFieldKeywordsMap: WeakMap<AutofillField, string> = new WeakMap();
@ -86,6 +84,8 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
return; return;
} }
this.formFieldElements.add(formFieldElement);
if (!this.autofillOverlayVisibility) { if (!this.autofillOverlayVisibility) {
await this.getAutofillOverlayVisibility(); await this.getAutofillOverlayVisibility();
} }
@ -901,10 +901,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
this.handleBodyElementMutationObserverUpdate, this.handleBodyElementMutationObserverUpdate,
); );
const documentElementMutationObserver = new MutationObserver( this.documentElementMutationObserver = new MutationObserver(
this.handleDocumentElementMutationObserverUpdate, this.handleDocumentElementMutationObserverUpdate,
); );
documentElementMutationObserver.observe(globalThis.document.documentElement, { this.documentElementMutationObserver.observe(globalThis.document.documentElement, {
childList: true, childList: true,
}); });
}; };
@ -1117,6 +1117,28 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
const documentRoot = element.getRootNode() as ShadowRoot | Document; const documentRoot = element.getRootNode() as ShadowRoot | Document;
return documentRoot?.activeElement; return documentRoot?.activeElement;
} }
/**
* Destroys the autofill overlay content service. This method will
* disconnect the mutation observers and remove all event listeners.
*/
destroy() {
this.documentElementMutationObserver?.disconnect();
this.clearUserInteractionEventTimeout();
this.formFieldElements.forEach((formFieldElement) => {
this.removeCachedFormFieldEventListeners(formFieldElement);
formFieldElement.removeEventListener(EVENTS.BLUR, this.handleFormFieldBlurEvent);
formFieldElement.removeEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent);
this.formFieldElements.delete(formFieldElement);
});
globalThis.document.removeEventListener(
EVENTS.VISIBILITYCHANGE,
this.handleVisibilityChangeEvent,
);
globalThis.removeEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent);
this.removeAutofillOverlay();
this.removeOverlayRepositionEventListeners();
}
} }
export default AutofillOverlayContentService; export default AutofillOverlayContentService;

View File

@ -2,7 +2,9 @@ import { mock, mockReset } from "jest-mock-extended";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
import { EventType } from "@bitwarden/common/enums"; import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ConfigService } from "@bitwarden/common/platform/services/config/config.service";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { SettingsService } from "@bitwarden/common/services/settings.service"; import { SettingsService } from "@bitwarden/common/services/settings.service";
import { import {
@ -24,6 +26,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserStateService } from "../../platform/services/browser-state.service"; import { BrowserStateService } from "../../platform/services/browser-state.service";
import { AutofillPort } from "../enums/autofill-port.enums";
import { import {
createAutofillFieldMock, createAutofillFieldMock,
createAutofillPageDetailsMock, createAutofillPageDetailsMock,
@ -54,6 +57,7 @@ describe("AutofillService", () => {
const logService = mock<LogService>(); const logService = mock<LogService>();
const settingsService = mock<SettingsService>(); const settingsService = mock<SettingsService>();
const userVerificationService = mock<UserVerificationService>(); const userVerificationService = mock<UserVerificationService>();
const configService = mock<ConfigService>();
beforeEach(() => { beforeEach(() => {
autofillService = new AutofillService( autofillService = new AutofillService(
@ -64,6 +68,7 @@ describe("AutofillService", () => {
logService, logService,
settingsService, settingsService,
userVerificationService, userVerificationService,
configService,
); );
}); });
@ -72,6 +77,72 @@ describe("AutofillService", () => {
mockReset(cipherService); mockReset(cipherService);
}); });
describe("loadAutofillScriptsOnInstall", () => {
let tab1: chrome.tabs.Tab;
let tab2: chrome.tabs.Tab;
let tab3: chrome.tabs.Tab;
beforeEach(() => {
tab1 = createChromeTabMock({ id: 1, url: "https://some-url.com" });
tab2 = createChromeTabMock({ id: 2, url: "http://some-url.com" });
tab3 = createChromeTabMock({ id: 3, url: "chrome-extension://some-extension-route" });
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([tab1, tab2]);
});
it("queries all browser tabs and injects the autofill scripts into them", async () => {
jest.spyOn(autofillService, "injectAutofillScripts");
await autofillService.loadAutofillScriptsOnInstall();
expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({});
expect(autofillService.injectAutofillScripts).toHaveBeenCalledWith(tab1, 0, false);
expect(autofillService.injectAutofillScripts).toHaveBeenCalledWith(tab2, 0, false);
});
it("skips injecting scripts into tabs that do not have an http(s) protocol", async () => {
jest.spyOn(autofillService, "injectAutofillScripts");
await autofillService.loadAutofillScriptsOnInstall();
expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({});
expect(autofillService.injectAutofillScripts).not.toHaveBeenCalledWith(tab3);
});
it("sets up an extension runtime onConnect listener", async () => {
await autofillService.loadAutofillScriptsOnInstall();
// eslint-disable-next-line no-restricted-syntax
expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledWith(expect.any(Function));
});
});
describe("reloadAutofillScripts", () => {
it("disconnects and removes all autofill script ports", () => {
const port1 = mock<chrome.runtime.Port>({
disconnect: jest.fn(),
});
const port2 = mock<chrome.runtime.Port>({
disconnect: jest.fn(),
});
autofillService["autofillScriptPortsSet"] = new Set([port1, port2]);
autofillService.reloadAutofillScripts();
expect(port1.disconnect).toHaveBeenCalled();
expect(port2.disconnect).toHaveBeenCalled();
expect(autofillService["autofillScriptPortsSet"].size).toBe(0);
});
it("re-injects the autofill scripts in all tabs", () => {
autofillService["autofillScriptPortsSet"] = new Set([mock<chrome.runtime.Port>()]);
jest.spyOn(autofillService as any, "injectAutofillScriptsInAllTabs");
autofillService.reloadAutofillScripts();
expect(autofillService["injectAutofillScriptsInAllTabs"]).toHaveBeenCalled();
});
});
describe("injectAutofillScripts", () => { describe("injectAutofillScripts", () => {
const autofillV1Script = "autofill.js"; const autofillV1Script = "autofill.js";
const autofillV2BootstrapScript = "bootstrap-autofill.js"; const autofillV2BootstrapScript = "bootstrap-autofill.js";
@ -83,12 +154,12 @@ describe("AutofillService", () => {
beforeEach(() => { beforeEach(() => {
tabMock = createChromeTabMock(); tabMock = createChromeTabMock();
sender = { tab: tabMock }; sender = { tab: tabMock, frameId: 1 };
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation(); jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
}); });
it("accepts an extension message sender and injects the autofill scripts into the tab of the sender", async () => { it("accepts an extension message sender and injects the autofill scripts into the tab of the sender", async () => {
await autofillService.injectAutofillScripts(sender); await autofillService.injectAutofillScripts(sender.tab, sender.frameId, true);
[autofillV1Script, ...defaultAutofillScripts].forEach((scriptName) => { [autofillV1Script, ...defaultAutofillScripts].forEach((scriptName) => {
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
@ -105,7 +176,11 @@ describe("AutofillService", () => {
}); });
it("will inject the bootstrap-autofill script if the enableAutofillV2 flag is set", async () => { it("will inject the bootstrap-autofill script if the enableAutofillV2 flag is set", async () => {
await autofillService.injectAutofillScripts(sender, true); jest
.spyOn(configService, "getFeatureFlag")
.mockImplementation((flag) => Promise.resolve(flag === FeatureFlag.AutofillV2));
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV2BootstrapScript}`, file: `content/${autofillV2BootstrapScript}`,
@ -120,11 +195,16 @@ describe("AutofillService", () => {
}); });
it("will inject the bootstrap-autofill-overlay script if the enableAutofillOverlay flag is set and the user has the autofill overlay enabled", async () => { it("will inject the bootstrap-autofill-overlay script if the enableAutofillOverlay flag is set and the user has the autofill overlay enabled", async () => {
jest
.spyOn(configService, "getFeatureFlag")
.mockImplementation((flag) =>
Promise.resolve(flag === FeatureFlag.AutofillOverlay || flag === FeatureFlag.AutofillV2),
);
jest jest
.spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility") .spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus);
await autofillService.injectAutofillScripts(sender, true, true); await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillOverlayBootstrapScript}`, file: `content/${autofillOverlayBootstrapScript}`,
@ -144,18 +224,25 @@ describe("AutofillService", () => {
}); });
it("will inject the bootstrap-autofill script if the enableAutofillOverlay flag is set but the user does not have the autofill overlay enabled", async () => { it("will inject the bootstrap-autofill script if the enableAutofillOverlay flag is set but the user does not have the autofill overlay enabled", async () => {
jest
.spyOn(configService, "getFeatureFlag")
.mockImplementation((flag) =>
Promise.resolve(flag === FeatureFlag.AutofillOverlay || flag === FeatureFlag.AutofillV2),
);
jest jest
.spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility") .spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.Off); .mockResolvedValue(AutofillOverlayVisibility.Off);
await autofillService.injectAutofillScripts(sender, true, true); await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV2BootstrapScript}`, file: `content/${autofillV2BootstrapScript}`,
frameId: sender.frameId,
...defaultExecuteScriptOptions, ...defaultExecuteScriptOptions,
}); });
expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, { expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV1Script}`, file: `content/${autofillV1Script}`,
frameId: sender.frameId,
...defaultExecuteScriptOptions, ...defaultExecuteScriptOptions,
}); });
}); });
@ -4436,4 +4523,58 @@ describe("AutofillService", () => {
expect(autofillService["currentlyOpeningPasswordRepromptPopout"]).toBe(false); expect(autofillService["currentlyOpeningPasswordRepromptPopout"]).toBe(false);
}); });
}); });
describe("handleInjectedScriptPortConnection", () => {
it("ignores port connections that do not have the correct port name", () => {
const port = mock<chrome.runtime.Port>({
name: "some-invalid-port-name",
onDisconnect: { addListener: jest.fn() },
}) as any;
autofillService["handleInjectedScriptPortConnection"](port);
expect(port.onDisconnect.addListener).not.toHaveBeenCalled();
expect(autofillService["autofillScriptPortsSet"].size).toBe(0);
});
it("adds the connect port to the set of injected script ports and sets up an onDisconnect listener", () => {
const port = mock<chrome.runtime.Port>({
name: AutofillPort.InjectedScript,
onDisconnect: { addListener: jest.fn() },
}) as any;
jest.spyOn(autofillService as any, "handleInjectScriptPortOnDisconnect");
autofillService["handleInjectedScriptPortConnection"](port);
expect(port.onDisconnect.addListener).toHaveBeenCalledWith(
autofillService["handleInjectScriptPortOnDisconnect"],
);
expect(autofillService["autofillScriptPortsSet"].size).toBe(1);
});
});
describe("handleInjectScriptPortOnDisconnect", () => {
it("ignores port disconnections that do not have the correct port name", () => {
autofillService["autofillScriptPortsSet"].add(mock<chrome.runtime.Port>());
autofillService["handleInjectScriptPortOnDisconnect"](
mock<chrome.runtime.Port>({
name: "some-invalid-port-name",
}),
);
expect(autofillService["autofillScriptPortsSet"].size).toBe(1);
});
it("removes the port from the set of injected script ports", () => {
const port = mock<chrome.runtime.Port>({
name: AutofillPort.InjectedScript,
}) as any;
autofillService["autofillScriptPortsSet"].add(port);
autofillService["handleInjectScriptPortOnDisconnect"](port);
expect(autofillService["autofillScriptPortsSet"].size).toBe(0);
});
});
}); });

View File

@ -2,6 +2,8 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve
import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { EventType } from "@bitwarden/common/enums"; import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
@ -13,6 +15,7 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service";
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window"; import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
import { AutofillPort } from "../enums/autofill-port.enums";
import AutofillField from "../models/autofill-field"; import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details"; import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script"; import AutofillScript from "../models/autofill-script";
@ -35,6 +38,7 @@ export default class AutofillService implements AutofillServiceInterface {
private openVaultItemPasswordRepromptPopout = openVaultItemPasswordRepromptPopout; private openVaultItemPasswordRepromptPopout = openVaultItemPasswordRepromptPopout;
private openPasswordRepromptPopoutDebounce: NodeJS.Timeout; private openPasswordRepromptPopoutDebounce: NodeJS.Timeout;
private currentlyOpeningPasswordRepromptPopout = false; private currentlyOpeningPasswordRepromptPopout = false;
private autofillScriptPortsSet = new Set<chrome.runtime.Port>();
constructor( constructor(
private cipherService: CipherService, private cipherService: CipherService,
@ -44,23 +48,54 @@ export default class AutofillService implements AutofillServiceInterface {
private logService: LogService, private logService: LogService,
private settingsService: SettingsService, private settingsService: SettingsService,
private userVerificationService: UserVerificationService, private userVerificationService: UserVerificationService,
private configService: ConfigServiceAbstraction,
) {} ) {}
/**
* Triggers on installation of the extension Handles injecting
* content scripts into all tabs that are currently open, and
* sets up a listener to ensure content scripts can identify
* if the extension context has been disconnected.
*/
async loadAutofillScriptsOnInstall() {
BrowserApi.addListener(chrome.runtime.onConnect, this.handleInjectedScriptPortConnection);
this.injectAutofillScriptsInAllTabs();
}
/**
* Triggers a complete reload of all autofill scripts on tabs open within
* the user's browsing session. This is done by first disconnecting all
* existing autofill content script ports, which cleans up existing object
* instances, and then re-injecting the autofill scripts into all tabs.
*/
async reloadAutofillScripts() {
this.autofillScriptPortsSet.forEach((port) => {
port.disconnect();
this.autofillScriptPortsSet.delete(port);
});
this.injectAutofillScriptsInAllTabs();
}
/** /**
* Injects the autofill scripts into the current tab and all frames * Injects the autofill scripts into the current tab and all frames
* found within the tab. Temporarily, will conditionally inject * found within the tab. Temporarily, will conditionally inject
* the refactor of the core autofill script if the feature flag * the refactor of the core autofill script if the feature flag
* is enabled. * is enabled.
* @param {chrome.runtime.MessageSender} sender * @param {chrome.tabs.Tab} tab
* @param {boolean} autofillV2 * @param {number} frameId
* @param {boolean} autofillOverlay * @param {boolean} triggeringOnPageLoad
* @returns {Promise<void>}
*/ */
async injectAutofillScripts( async injectAutofillScripts(
sender: chrome.runtime.MessageSender, tab: chrome.tabs.Tab,
autofillV2 = false, frameId = 0,
autofillOverlay = false, triggeringOnPageLoad = true,
) { ): Promise<void> {
const autofillV2 = await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillV2);
const autofillOverlay = await this.configService.getFeatureFlag<boolean>(
FeatureFlag.AutofillOverlay,
);
let mainAutofillScript = "autofill.js"; let mainAutofillScript = "autofill.js";
const isUsingAutofillOverlay = const isUsingAutofillOverlay =
@ -73,20 +108,24 @@ export default class AutofillService implements AutofillServiceInterface {
: "bootstrap-autofill.js"; : "bootstrap-autofill.js";
} }
const injectedScripts = [ const injectedScripts = [mainAutofillScript];
mainAutofillScript, if (triggeringOnPageLoad) {
"autofiller.js", injectedScripts.push("autofiller.js");
"notificationBar.js", }
"contextMenuHandler.js", injectedScripts.push("notificationBar.js", "contextMenuHandler.js");
];
for (const injectedScript of injectedScripts) { for (const injectedScript of injectedScripts) {
await BrowserApi.executeScriptInTab(sender.tab.id, { await BrowserApi.executeScriptInTab(tab.id, {
file: `content/${injectedScript}`, file: `content/${injectedScript}`,
frameId: sender.frameId, frameId,
runAt: "document_start", runAt: "document_start",
}); });
} }
await BrowserApi.executeScriptInTab(tab.id, {
file: "content/message_handler.js",
runAt: "document_start",
});
} }
/** /**
@ -1877,4 +1916,47 @@ export default class AutofillService implements AutofillServiceInterface {
return false; return false;
} }
/**
* Handles incoming long-lived connections from injected autofill scripts.
* Stores the port in a set to facilitate disconnecting ports if the extension
* needs to re-inject the autofill scripts.
*
* @param port - The port that was connected
*/
private handleInjectedScriptPortConnection = (port: chrome.runtime.Port) => {
if (port.name !== AutofillPort.InjectedScript) {
return;
}
this.autofillScriptPortsSet.add(port);
port.onDisconnect.addListener(this.handleInjectScriptPortOnDisconnect);
};
/**
* Handles disconnecting ports that relate to injected autofill scripts.
* @param port - The port that was disconnected
*/
private handleInjectScriptPortOnDisconnect = (port: chrome.runtime.Port) => {
if (port.name !== AutofillPort.InjectedScript) {
return;
}
this.autofillScriptPortsSet.delete(port);
};
/**
* Queries all open tabs in the user's browsing session
* and injects the autofill scripts into the page.
*/
private async injectAutofillScriptsInAllTabs() {
const tabs = await BrowserApi.tabsQuery({});
for (let index = 0; index < tabs.length; index++) {
const tab = tabs[index];
if (tab.url?.startsWith("http")) {
this.injectAutofillScripts(tab, 0, false);
}
}
}
} }

View File

@ -1249,6 +1249,17 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
return attributeValue; return attributeValue;
} }
/**
* Destroys the CollectAutofillContentService. Clears all
* timeouts and disconnects the mutation observer.
*/
destroy() {
if (this.updateAutofillElementsAfterMutationTimeout) {
clearTimeout(this.updateAutofillElementsAfterMutationTimeout);
}
this.mutationObserver?.disconnect();
}
} }
export default CollectAutofillContentService; export default CollectAutofillContentService;

View File

@ -1,10 +1,17 @@
import { AutofillPort } from "../enums/autofill-port.enums";
import { triggerPortOnDisconnectEvent } from "../jest/testing-utils";
import { logoIcon, logoLockedIcon } from "./svg-icons"; import { logoIcon, logoLockedIcon } from "./svg-icons";
import { import {
buildSvgDomElement, buildSvgDomElement,
generateRandomCustomElementName, generateRandomCustomElementName,
sendExtensionMessage, sendExtensionMessage,
setElementStyles, setElementStyles,
} from "./utils"; getFromLocalStorage,
setupExtensionDisconnectAction,
setupAutofillInitDisconnectAction,
} from "./index";
describe("buildSvgDomElement", () => { describe("buildSvgDomElement", () => {
it("returns an SVG DOM element", () => { it("returns an SVG DOM element", () => {
@ -116,3 +123,107 @@ describe("setElementStyles", () => {
expect(testDiv.style.cssText).toEqual(expectedCSSRuleString); expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);
}); });
}); });
describe("getFromLocalStorage", () => {
it("returns a promise with the storage object pulled from the extension storage api", async () => {
const localStorage: Record<string, any> = {
testValue: "test",
another: "another",
};
jest.spyOn(chrome.storage.local, "get").mockImplementation((keys, callback) => {
const localStorageObject: Record<string, string> = {};
if (typeof keys === "string") {
localStorageObject[keys] = localStorage[keys];
} else if (Array.isArray(keys)) {
for (const key of keys) {
localStorageObject[key] = localStorage[key];
}
}
callback(localStorageObject);
});
const returnValue = await getFromLocalStorage("testValue");
expect(chrome.storage.local.get).toHaveBeenCalled();
expect(returnValue).toEqual({ testValue: "test" });
});
});
describe("setupExtensionDisconnectAction", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("connects a port to the extension background and sets up an onDisconnect listener", () => {
const onDisconnectCallback = jest.fn();
let port: chrome.runtime.Port;
jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
port = {
onDisconnect: {
addListener: onDisconnectCallback,
removeListener: jest.fn(),
},
} as unknown as chrome.runtime.Port;
return port;
});
setupExtensionDisconnectAction(onDisconnectCallback);
expect(chrome.runtime.connect).toHaveBeenCalledWith({
name: AutofillPort.InjectedScript,
});
expect(port.onDisconnect.addListener).toHaveBeenCalledWith(expect.any(Function));
});
});
describe("setupAutofillInitDisconnectAction", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("skips setting up the extension disconnect action if the bitwardenAutofillInit object is not populated", () => {
const onDisconnectCallback = jest.fn();
window.bitwardenAutofillInit = undefined;
const portConnectSpy = jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
return {
onDisconnect: {
addListener: onDisconnectCallback,
removeListener: jest.fn(),
},
} as unknown as chrome.runtime.Port;
});
setupAutofillInitDisconnectAction(window);
expect(portConnectSpy).not.toHaveBeenCalled();
});
it("destroys the autofill init instance when the port is disconnected", () => {
let port: chrome.runtime.Port;
const autofillInitDestroy: CallableFunction = jest.fn();
window.bitwardenAutofillInit = {
destroy: autofillInitDestroy,
} as any;
jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
port = {
onDisconnect: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
} as unknown as chrome.runtime.Port;
return port;
});
setupAutofillInitDisconnectAction(window);
triggerPortOnDisconnectEvent(port as chrome.runtime.Port);
expect(chrome.runtime.connect).toHaveBeenCalled();
expect(port.onDisconnect.addListener).toHaveBeenCalled();
expect(autofillInitDestroy).toHaveBeenCalled();
expect(window.bitwardenAutofillInit).toBeUndefined();
});
});

View File

@ -1,3 +1,5 @@
import { AutofillPort } from "../enums/autofill-port.enums";
/** /**
* Generates a random string of characters that formatted as a custom element name. * Generates a random string of characters that formatted as a custom element name.
*/ */
@ -103,9 +105,57 @@ function setElementStyles(
} }
} }
/**
* Get data from local storage based on the keys provided.
*
* @param keys - String or array of strings of keys to get from local storage
*/
async function getFromLocalStorage(keys: string | string[]): Promise<Record<string, any>> {
return new Promise((resolve) => {
chrome.storage.local.get(keys, (storage: Record<string, any>) => resolve(storage));
});
}
/**
* Sets up a long-lived connection with the extension background
* and triggers an onDisconnect event if the extension context
* is invalidated.
*
* @param callback - Callback function to run when the extension disconnects
*/
function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => void) {
const port = chrome.runtime.connect({ name: AutofillPort.InjectedScript });
const onDisconnectCallback = (disconnectedPort: chrome.runtime.Port) => {
callback(disconnectedPort);
port.onDisconnect.removeListener(onDisconnectCallback);
};
port.onDisconnect.addListener(onDisconnectCallback);
}
/**
* Handles setup of the extension disconnect action for the autofill init class
* in both instances where the overlay might or might not be initialized.
*
* @param windowContext - The global window context
*/
function setupAutofillInitDisconnectAction(windowContext: Window) {
if (!windowContext.bitwardenAutofillInit) {
return;
}
const onDisconnectCallback = () => {
windowContext.bitwardenAutofillInit.destroy();
delete windowContext.bitwardenAutofillInit;
};
setupExtensionDisconnectAction(onDisconnectCallback);
}
export { export {
generateRandomCustomElementName, generateRandomCustomElementName,
buildSvgDomElement, buildSvgDomElement,
sendExtensionMessage, sendExtensionMessage,
setElementStyles, setElementStyles,
getFromLocalStorage,
setupExtensionDisconnectAction,
setupAutofillInitDisconnectAction,
}; };

View File

@ -566,6 +566,7 @@ export default class MainBackground {
this.logService, this.logService,
this.settingsService, this.settingsService,
this.userVerificationService, this.userVerificationService,
this.configService,
); );
this.auditService = new AuditService(this.cryptoFunctionService, this.apiService); this.auditService = new AuditService(this.cryptoFunctionService, this.apiService);

View File

@ -1,5 +1,4 @@
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@ -97,9 +96,9 @@ export default class RuntimeBackground {
await closeUnlockPopout(); await closeUnlockPopout();
} }
await this.notificationsService.updateConnection(msg.command === "loggedIn");
await this.main.refreshBadge(); await this.main.refreshBadge();
await this.main.refreshMenu(false); await this.main.refreshMenu(false);
this.notificationsService.updateConnection(msg.command === "unlocked");
this.systemService.cancelProcessReload(); this.systemService.cancelProcessReload();
if (item) { if (item) {
@ -133,11 +132,7 @@ export default class RuntimeBackground {
await this.main.openPopup(); await this.main.openPopup();
break; break;
case "triggerAutofillScriptInjection": case "triggerAutofillScriptInjection":
await this.autofillService.injectAutofillScripts( await this.autofillService.injectAutofillScripts(sender.tab, sender.frameId);
sender,
await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillV2),
await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillOverlay),
);
break; break;
case "bgCollectPageDetails": case "bgCollectPageDetails":
await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId); await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId);
@ -325,6 +320,8 @@ export default class RuntimeBackground {
private async checkOnInstalled() { private async checkOnInstalled() {
setTimeout(async () => { setTimeout(async () => {
this.autofillService.loadAutofillScriptsOnInstall();
if (this.onInstalledReason != null) { if (this.onInstalledReason != null) {
if (this.onInstalledReason === "install") { if (this.onInstalledReason === "install") {
BrowserApi.createNewTab("https://bitwarden.com/browser-start/"); BrowserApi.createNewTab("https://bitwarden.com/browser-start/");

View File

@ -24,12 +24,6 @@
"matches": ["http://*/*", "https://*/*", "file:///*"], "matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start" "run_at": "document_start"
}, },
{
"all_frames": false,
"js": ["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

@ -23,11 +23,12 @@ const runtime = {
removeListener: jest.fn(), removeListener: jest.fn(),
}, },
sendMessage: jest.fn(), sendMessage: jest.fn(),
getManifest: jest.fn(), getManifest: jest.fn(() => ({ version: 2 })),
getURL: jest.fn((path) => `chrome-extension://id/${path}`), getURL: jest.fn((path) => `chrome-extension://id/${path}`),
connect: jest.fn(), connect: jest.fn(),
onConnect: { onConnect: {
addListener: jest.fn(), addListener: jest.fn(),
removeListener: jest.fn(),
}, },
}; };