[PM-5876] Adjust the `LpFilelessImporter.supressDownload` method to inject through the `executeScript` API instead within manifest v3 (#7787)

* [PM-5876] Adjust LP Fileless Importer to Suppress Download with DOM Append in Manifest v3

* [PM-5876] Incorporating jest tests for affected logic

* [PM-5876] Fixing jest test that leverages rxjs

* [PM-5876] Updating documentation within BrowserApi.executeScriptInTab

* [PM-5876] Implementing jest tests for the new LP suppress download content scripts

* [PM-5876] Adding a change to webpack to ensure we do not package the mv2 side script for `lp-suppress-import-download.mv2.ts` if building the extension for mv3

* [PM-5876] Implementing changes based on feedback during code review

* [PM-5876] Implementing changes based on feedback during code review

* [PM-5876] Implementing changes based on feedback during code review

* [PM-5876] Implementing changes based on feedback during code review

* [PM-5876] Implementing a configuration to feed the script injection of the Fileless Importer CSV download supression script
This commit is contained in:
Cesar Gonzalez 2024-03-05 13:39:58 -06:00 committed by GitHub
parent 101e1a4f2b
commit 16c5fe65ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 338 additions and 68 deletions

View File

@ -108,6 +108,7 @@
},
"web_accessible_resources": [
"content/fido2/page-script.js",
"content/lp-suppress-import-download.js",
"notification/bar.html",
"images/icon38.png",
"images/icon38_locked.png",

View File

@ -31,6 +31,12 @@
"matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start"
},
{
"all_frames": false,
"js": ["content/lp-fileless-importer.js"],
"matches": ["https://lastpass.com/export.php"],
"run_at": "document_start"
},
{
"all_frames": true,
"css": ["content/autofill.css"],

View File

@ -320,6 +320,60 @@ describe("BrowserApi", () => {
},
files: [injectDetails.file],
injectImmediately: true,
world: "ISOLATED",
});
expect(result).toEqual(executeScriptResult);
});
it("injects the script into a specified frameId when the extension is built for manifest v3", async () => {
const tabId = 1;
const frameId = 2;
const injectDetails = mock<chrome.tabs.InjectDetails>({
file: "file.js",
allFrames: true,
runAt: "document_start",
frameId,
});
jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
(chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult);
await BrowserApi.executeScriptInTab(tabId, injectDetails);
expect(chrome.scripting.executeScript).toHaveBeenCalledWith({
target: {
tabId: tabId,
allFrames: injectDetails.allFrames,
frameIds: [frameId],
},
files: [injectDetails.file],
injectImmediately: true,
world: "ISOLATED",
});
});
it("injects the script into the MAIN world context when injecting a script for manifest v3", async () => {
const tabId = 1;
const injectDetails = mock<chrome.tabs.InjectDetails>({
file: null,
allFrames: true,
runAt: "document_start",
frameId: null,
});
const scriptingApiDetails = { world: "MAIN" as chrome.scripting.ExecutionWorld };
jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
(chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult);
const result = await BrowserApi.executeScriptInTab(tabId, injectDetails, scriptingApiDetails);
expect(chrome.scripting.executeScript).toHaveBeenCalledWith({
target: {
tabId: tabId,
allFrames: injectDetails.allFrames,
frameIds: null,
},
files: null,
injectImmediately: true,
world: "MAIN",
});
expect(result).toEqual(executeScriptResult);
});

View File

@ -475,12 +475,19 @@ export class BrowserApi {
/**
* Extension API helper method used to execute a script in a tab.
*
* @see https://developer.chrome.com/docs/extensions/reference/tabs/#method-executeScript
* @param {number} tabId
* @param {chrome.tabs.InjectDetails} details
* @returns {Promise<unknown>}
* @param tabId - The id of the tab to execute the script in.
* @param details {@link "InjectDetails" https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/extensionTypes/InjectDetails}
* @param scriptingApiDetails {@link "ExecutionWorld" https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld}
*/
static executeScriptInTab(tabId: number, details: chrome.tabs.InjectDetails) {
static executeScriptInTab(
tabId: number,
details: chrome.tabs.InjectDetails,
scriptingApiDetails?: {
world: chrome.scripting.ExecutionWorld;
},
): Promise<unknown> {
if (BrowserApi.manifestVersion === 3) {
return chrome.scripting.executeScript({
target: {
@ -490,6 +497,7 @@ export class BrowserApi {
},
files: details.file ? [details.file] : null,
injectImmediately: details.runAt === "document_start",
world: scriptingApiDetails?.world || "ISOLATED",
});
}

View File

@ -1,5 +1,10 @@
import { FilelessImportTypeKeys } from "../../enums/fileless-import.enums";
type SuppressDownloadScriptInjectionConfig = {
file: string;
scriptingApiDetails?: { world: chrome.scripting.ExecutionWorld };
};
type FilelessImportPortMessage = {
command?: string;
importType?: FilelessImportTypeKeys;
@ -27,6 +32,7 @@ interface FilelessImporterBackground {
}
export {
SuppressDownloadScriptInjectionConfig,
FilelessImportPortMessage,
ImportNotificationMessageHandlers,
LpImporterMessageHandlers,

View File

@ -1,4 +1,5 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@ -14,10 +15,20 @@ import {
sendPortMessage,
triggerRuntimeOnConnectEvent,
} from "../../autofill/spec/testing-utils";
import { BrowserApi } from "../../platform/browser/browser-api";
import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import.enums";
import FilelessImporterBackground from "./fileless-importer.background";
jest.mock("rxjs", () => {
const rxjs = jest.requireActual("rxjs");
const { firstValueFrom } = rxjs;
return {
...rxjs,
firstValueFrom: jest.fn(firstValueFrom),
};
});
describe("FilelessImporterBackground ", () => {
let filelessImporterBackground: FilelessImporterBackground;
const configService = mock<ConfigService>();
@ -51,14 +62,17 @@ describe("FilelessImporterBackground ", () => {
describe("handle ports onConnect", () => {
let lpImporterPort: chrome.runtime.Port;
let manifestVersionSpy: jest.SpyInstance;
let executeScriptInTabSpy: jest.SpyInstance;
beforeEach(() => {
lpImporterPort = createPortSpyMock(FilelessImportPort.LpImporter);
manifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get");
executeScriptInTabSpy = jest.spyOn(BrowserApi, "executeScriptInTab").mockResolvedValue(null);
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
jest
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
.mockResolvedValue(false);
jest.spyOn(filelessImporterBackground as any, "removeIndividualVault");
(firstValueFrom as jest.Mock).mockResolvedValue(false);
});
it("ignores the port connection if the port name is not present in the set of filelessImportNames", async () => {
@ -83,9 +97,7 @@ describe("FilelessImporterBackground ", () => {
});
it("posts a message to the port indicating that the fileless import feature is disabled if the user's policy removes individual vaults", async () => {
jest
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
.mockResolvedValue(true);
(firstValueFrom as jest.Mock).mockResolvedValue(true);
triggerRuntimeOnConnectEvent(lpImporterPort);
await flushPromises();
@ -117,6 +129,35 @@ describe("FilelessImporterBackground ", () => {
filelessImportEnabled: true,
});
});
it("triggers an injection of the `lp-suppress-import-download.js` script in manifest v3", async () => {
manifestVersionSpy.mockReturnValue(3);
triggerRuntimeOnConnectEvent(lpImporterPort);
await flushPromises();
expect(executeScriptInTabSpy).toHaveBeenCalledWith(
lpImporterPort.sender.tab.id,
{ file: "content/lp-suppress-import-download.js", runAt: "document_start" },
{ world: "MAIN" },
);
});
it("triggers an injection of the `lp-suppress-import-download-script-append-mv2.js` script in manifest v2", async () => {
manifestVersionSpy.mockReturnValue(2);
triggerRuntimeOnConnectEvent(lpImporterPort);
await flushPromises();
expect(executeScriptInTabSpy).toHaveBeenCalledWith(
lpImporterPort.sender.tab.id,
{
file: "content/lp-suppress-import-download-script-append-mv2.js",
runAt: "document_start",
},
undefined,
);
});
});
describe("port messages", () => {
@ -126,9 +167,7 @@ describe("FilelessImporterBackground ", () => {
beforeEach(async () => {
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
jest
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
.mockResolvedValue(false);
(firstValueFrom as jest.Mock).mockResolvedValue(false);
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.NotificationBar));
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.LpImporter));
await flushPromises();

View File

@ -11,6 +11,7 @@ import { ImportServiceAbstraction } from "@bitwarden/importer/core";
import NotificationBackground from "../../autofill/background/notification.background";
import { BrowserApi } from "../../platform/browser/browser-api";
import { FilelessImporterInjectedScriptsConfig } from "../config/fileless-importer-injected-scripts";
import {
FilelessImportPort,
FilelessImportType,
@ -22,6 +23,7 @@ import {
LpImporterMessageHandlers,
FilelessImporterBackground as FilelessImporterBackgroundInterface,
FilelessImportPortMessage,
SuppressDownloadScriptInjectionConfig,
} from "./abstractions/fileless-importer.background";
class FilelessImporterBackground implements FilelessImporterBackgroundInterface {
@ -108,6 +110,23 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
await this.notificationBackground.requestFilelessImport(tab, importType);
}
/**
* Injects the script used to suppress the download of the LP importer export file.
*
* @param sender - The sender of the message.
* @param injectionConfig - The configuration for the injection.
*/
private async injectScriptConfig(
sender: chrome.runtime.MessageSender,
injectionConfig: SuppressDownloadScriptInjectionConfig,
) {
await BrowserApi.executeScriptInTab(
sender.tab.id,
{ file: injectionConfig.file, runAt: "document_start" },
injectionConfig.scriptingApiDetails,
);
}
/**
* Triggers the download of the CSV file from the LP importer. This is triggered
* when the user opts to not save the export to Bitwarden within the notification bar.
@ -200,6 +219,12 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
switch (port.name) {
case FilelessImportPort.LpImporter:
this.lpImporterPort = port;
await this.injectScriptConfig(
port.sender,
BrowserApi.manifestVersion === 3
? FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv3
: FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv2,
);
break;
case FilelessImportPort.NotificationBar:
this.importNotificationsPort = port;

View File

@ -0,0 +1,22 @@
import { SuppressDownloadScriptInjectionConfig } from "../background/abstractions/fileless-importer.background";
type FilelessImporterInjectedScriptsConfigurations = {
LpSuppressImportDownload: {
mv2: SuppressDownloadScriptInjectionConfig;
mv3: SuppressDownloadScriptInjectionConfig;
};
};
const FilelessImporterInjectedScriptsConfig: FilelessImporterInjectedScriptsConfigurations = {
LpSuppressImportDownload: {
mv2: {
file: "content/lp-suppress-import-download-script-append-mv2.js",
},
mv3: {
file: "content/lp-suppress-import-download.js",
scriptingApiDetails: { world: "MAIN" },
},
},
} as const;
export { FilelessImporterInjectedScriptsConfig };

View File

@ -43,20 +43,6 @@ describe("LpFilelessImporter", () => {
expect(portSpy.disconnect).toHaveBeenCalled();
});
it("injects a script element that suppresses the download of the LastPass export", () => {
const script = document.createElement("script");
jest.spyOn(document, "createElement").mockReturnValue(script);
jest.spyOn(document.documentElement, "appendChild");
lpFilelessImporter.handleFeatureFlagVerification({ filelessImportEnabled: true });
expect(document.createElement).toHaveBeenCalledWith("script");
expect(document.documentElement.appendChild).toHaveBeenCalled();
expect(script.textContent).toContain(
"const defaultAppendChild = Element.prototype.appendChild;",
);
});
it("sets up an event listener for DOMContentLoaded that triggers the importer when the document ready state is `loading`", () => {
Object.defineProperty(document, "readyState", {
value: "loading",

View File

@ -36,7 +36,6 @@ class LpFilelessImporter implements LpFilelessImporterInterface {
return;
}
this.suppressDownload();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", this.loadImporter);
return;
@ -52,46 +51,6 @@ class LpFilelessImporter implements LpFilelessImporterInterface {
this.postWindowMessage({ command: "triggerCsvDownload" });
}
/**
* Suppresses the download of the CSV file by overriding the `download` attribute of the
* anchor element that is created by the LP importer. This is done by injecting a script
* into the page that overrides the `appendChild` method of the `Element` prototype.
*/
private suppressDownload() {
const script = document.createElement("script");
script.textContent = `
let csvDownload = '';
let csvHref = '';
const defaultAppendChild = Element.prototype.appendChild;
Element.prototype.appendChild = function (newChild) {
if (newChild.nodeName.toLowerCase() === 'a' && newChild.download) {
csvDownload = newChild.download;
csvHref = newChild.href;
newChild.setAttribute('href', 'javascript:void(0)');
newChild.setAttribute('download', '');
Element.prototype.appendChild = defaultAppendChild;
}
return defaultAppendChild.call(this, newChild);
};
window.addEventListener('message', (event) => {
const command = event.data?.command;
if (event.source !== window || command !== 'triggerCsvDownload') {
return;
}
const anchor = document.createElement('a');
anchor.setAttribute('href', csvHref);
anchor.setAttribute('download', csvDownload);
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
});
`;
document.documentElement.appendChild(script);
}
/**
* Initializes the importing mechanism used to import the CSV file into Bitwarden.
* This is done by observing the DOM for the addition of the LP importer element.

View File

@ -0,0 +1,21 @@
describe("LP Suppress Import Download for Manifest v2", () => {
it("appends the `lp-suppress-import-download.js` script to the document element", () => {
let createdScriptElement: HTMLScriptElement;
jest.spyOn(window.document, "createElement");
jest.spyOn(window.document.documentElement, "appendChild").mockImplementation((node) => {
createdScriptElement = node as HTMLScriptElement;
return node;
});
require("./lp-suppress-import-download-script-append.mv2");
expect(window.document.createElement).toHaveBeenCalledWith("script");
expect(chrome.runtime.getURL).toHaveBeenCalledWith("content/lp-suppress-import-download.js");
expect(window.document.documentElement.appendChild).toHaveBeenCalledWith(
expect.any(HTMLScriptElement),
);
expect(createdScriptElement.src).toBe(
"chrome-extension://id/content/lp-suppress-import-download.js",
);
});
});

View File

@ -0,0 +1,9 @@
/**
* This script handles injection of the LP suppress import download script into the document.
* This is required for manifest v2, but will be removed when we migrate fully to manifest v3.
*/
(function (globalContext) {
const script = globalContext.document.createElement("script");
script.src = chrome.runtime.getURL("content/lp-suppress-import-download.js");
globalContext.document.documentElement.appendChild(script);
})(window);

View File

@ -0,0 +1,81 @@
import { flushPromises, postWindowMessage } from "../../autofill/spec/testing-utils";
describe("LP Suppress Import Download", () => {
const downloadAttribute = "file.csv";
const hrefAttribute = "https://example.com/file.csv";
const overridenHrefAttribute = "javascript:void(0)";
let anchor: HTMLAnchorElement;
beforeEach(() => {
jest.spyOn(Element.prototype, "appendChild");
jest.spyOn(window, "addEventListener");
require("./lp-suppress-import-download");
anchor = document.createElement("a");
anchor.download = downloadAttribute;
anchor.href = hrefAttribute;
anchor.click = jest.fn();
});
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
it("disables the automatic download anchor", () => {
document.body.appendChild(anchor);
expect(anchor.href).toBe(overridenHrefAttribute);
expect(anchor.download).toBe("");
});
it("triggers the CSVDownload when receiving a `triggerCsvDownload` window message", async () => {
window.document.createElement = jest.fn(() => anchor);
jest.spyOn(window, "removeEventListener");
document.body.appendChild(anchor);
// Precondition - Ensure the anchor in the document has overridden href and download attributes
expect(anchor.href).toBe(overridenHrefAttribute);
expect(anchor.download).toBe("");
postWindowMessage({ command: "triggerCsvDownload" });
await flushPromises();
expect(anchor.click).toHaveBeenCalled();
expect(anchor.href).toEqual(hrefAttribute);
expect(anchor.download).toEqual(downloadAttribute);
expect(window.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function));
});
it("skips subsequent calls to trigger a CSVDownload", async () => {
window.document.createElement = jest.fn(() => anchor);
document.body.appendChild(anchor);
postWindowMessage({ command: "triggerCsvDownload" });
await flushPromises();
postWindowMessage({ command: "triggerCsvDownload" });
await flushPromises();
expect(anchor.click).toHaveBeenCalledTimes(1);
});
it("skips triggering the CSV download for window messages that do not have the correct command", () => {
document.body.appendChild(anchor);
postWindowMessage({ command: "notTriggerCsvDownload" });
expect(anchor.click).not.toHaveBeenCalled();
});
it("skips triggering the CSV download for window messages that do not have a data value", () => {
document.body.appendChild(anchor);
postWindowMessage(null);
expect(anchor.click).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,50 @@
/**
* Handles intercepting the injection of the CSV download link, and ensures the
* download of the script is suppressed until the user opts to download the file.
* The download is triggered by a window message sent from the LpFilelessImporter
* content script.
*/
(function (globalContext) {
let csvDownload = "";
let csvHref = "";
let isCsvDownloadTriggered = false;
const defaultAppendChild = Element.prototype.appendChild;
Element.prototype.appendChild = function (newChild: Node) {
if (isAnchorElement(newChild) && newChild.download) {
csvDownload = newChild.download;
csvHref = newChild.href;
newChild.setAttribute("href", "javascript:void(0)");
newChild.setAttribute("download", "");
Element.prototype.appendChild = defaultAppendChild;
}
return defaultAppendChild.call(this, newChild);
};
function isAnchorElement(node: Node): node is HTMLAnchorElement {
return node.nodeName.toLowerCase() === "a";
}
const handleWindowMessage = (event: MessageEvent) => {
const command = event.data?.command;
if (
event.source !== globalContext ||
command !== "triggerCsvDownload" ||
isCsvDownloadTriggered
) {
return;
}
isCsvDownloadTriggered = true;
globalContext.removeEventListener("message", handleWindowMessage);
const anchor = globalContext.document.createElement("a");
anchor.setAttribute("href", csvHref);
anchor.setAttribute("download", csvDownload);
globalContext.document.body.appendChild(anchor);
anchor.click();
globalContext.document.body.removeChild(anchor);
};
globalContext.addEventListener("message", handleWindowMessage);
})(window);

View File

@ -179,6 +179,7 @@ const mainConfig = {
"overlay/list": "./src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts",
"encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts",
"content/lp-fileless-importer": "./src/tools/content/lp-fileless-importer.ts",
"content/lp-suppress-import-download": "./src/tools/content/lp-suppress-import-download.ts",
},
optimization: {
minimize: ENV !== "development",
@ -276,6 +277,8 @@ if (manifestVersion == 2) {
// Manifest V2 background pages can be run through the regular build pipeline.
// Since it's a standard webpage.
mainConfig.entry.background = "./src/platform/background.ts";
mainConfig.entry["content/lp-suppress-import-download-script-append-mv2"] =
"./src/tools/content/lp-suppress-import-download-script-append.mv2.ts";
configs.push(mainConfig);
} else {