bitwarden-estensione-browser/apps/browser/src/platform/browser/browser-api.register-conten...

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

436 lines
13 KiB
TypeScript
Raw Normal View History

[PM-5744] Adjust Fido2 Content Script Injection to Meet mv3 Requirements (#8222) * [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 * [PM-5744] Adjust injection of `page-script.ts` within FIDO 2 implementation to ensure mv3 compatibility * [PM-5744] Adjusting structure of manifest.json to clean up implementation and ensure consistency between mv2 and mv3 * [PM-5744] Reverting inclusion of the ConsoleLogService * [PM-4791] Injected content scripts prevent proper XML file display and disrupt XML responses * [PM-5744] Adjust FIDO2 content script injection methodology to be compatible with manifest v3 * [PM-5744] Adjusting references to Fido2Service to mirror change of name to Fido2Background * [PM-5744] Migrating runtime background messages that are associated with Fido2 into Fido2Background * [PM-5744] Fixing named reference within Fido2Background * [PM-5744] Migrating all Fido2 messages from the runtime.background.ts script to the fido2.background.ts script * [PM-5744] Removing unnecessary dependency from runtime background * [PM-5744] Removing unnecessary dependency from runtime background * [PM-5744] Reworking how we handle init of Fido2Background * [PM-5744] Reworking page-script.ts to ensure that it can destory its global values on unload * [PM-5744] Reworking page-script.ts to ensure that it can destory its global values on unload * [PM-5744] Implementing separated injection methodology between manifest v2 and v3 * [PM-4791] Adjsuting reference for Fido2 script injection to ensure it only triggers on https protocol types * [PM-5744] Removing unnecessary message and handling reload of content scripts based on updates on observable * [PM-5744] Refactoring content-script implementation for fido2 * [PM-5744] Refactoring content-script implementation for fido2 * [PM-5744] Reworking implementation to avoid having multiple contenType checks within the FIDO2 content scripts * [PM-5744] Re-implementing the messageWithResponse within runtime.background.ts * [PM-5744] Reverting change to autofill.service.ts * [PM-5744] Removing return value from runtime.background.ts process message call * [PM-5744] Reworking how we handle injection of the fido2 page and content script elements * [PM-5744] Adjusting how we override the navigator.credentials request/reponse structure * [PM-5744] Working through jest tests for the fido2Background implementation * [PM-5744] Finalizing jest tests for the Fido2Background implementation * [PM-5744] Stubbing out jest tests for content-script and page-script * [PM-5744] Implementing a methodology that allows us to dynamically set and unset content scripts * [PM-5744] Applying cleanup to page-script.ts to lighten the footprint of the script * [PM-5744] Further simplifying page-script implementation * [PM-5744] Reworking Fido2Utils to remove references to the base Utils methods to allow the page-script.ts file to render at a lower file size * [PM-5744] Reworking Fido2Utils to remove references to the base Utils methods to allow the page-script.ts file to render at a lower file size * [PM-5744] Implementing the `RegisterContentScriptPolyfill` as a separately compiled file as opposed to an import * [PM-5744] Implementing methodology to ensure that the RegisterContentScript polyfill is not built in cases where it is not needed * [PM-5744] Implementing methodology to ensure that the RegisterContentScript polyfill is not built in cases where it is not needed * [PM-5744] Reverting package-lock.json * [PM-5744] Implementing a methodology to ensure we can instantiate the RegisterContentScript polyfill in a siloed manner * [PM-5744] Migrating chrome extension api calls to the BrowserApi class * [PM-5744] Implementing typing information within the RegisterContentScriptsPolyfill * [PM-5744] Removing any eslint-disable references within the RegisterContentScriptsPolyfill * [PM-5744] Refactoring polyfill implementation * [PM-5744] Refactoring polyfill implementation * [PM-5744] Fixing an issue where Safari was not resolving the await chrome proxy * [PM-5744] Fixing jest tests for the page-script append method * [PM-5744] Fixing an issue found where collection of page details can trigger a context invalidated message when the extension is refreshed * [PM-5744] Implementing jest tests for the added BrowserApi methods * [PM-5744] Refactoring Fido2Background implementation * [PM-5744] Refactoring Fido2Background implementation * [PM-5744] Adding enums to the implementation for the Fido2 Content Scripts and working through jest tests for the BrowserApi and Fido2Background classes * [PM-5744] Adding comments to the FIDO2 content-script.ts file * [PM-5744] Adding jest tests for the Fido2 content-script.ts * [PM-5744] Adding jest tests for the Fido2 content-script.ts * [PM-5744] Adding jest tests for the Fido2 page-script.ts * [PM-5744] Working through an attempt to jest test the page-script.ts file * [PM-5744] Finalizing jest tests for the page-script.ts implementation * [PM-5744] Applying stricter type information for the passed params within fido2-testing-utils.ts * [PM-5744] Adjusting documentation * [PM-5744] Adjusting implementation of jest tests to use mock proxies * [PM-5744] Adjusting jest tests to simply implementation * [PM-5744] Adjust jest tests based on code review feedback * [PM-5744] Adjust jest tests based on code review feedback * [PM-5744] Adjust jest tests based on code review feedback * [PM-5744] Adjusting jest tests to based on feedback * [PM-5744] Adjusting jest tests to based on feedback * [PM-5744] Adjusting jest tests to based on feedback * [PM-5744] Adjusting conditional within page-script.ts * [PM-5744] Removing unnecessary global reference to the messager * [PM-5744] Updating jest tests * [PM-5744] Updating jest tests * [PM-5744] Updating jest tests * [PM-5744] Updating jest tests * [PM-5744] Updating how we export the Fido2Background class * [PM-5744] Adding duplciate jest tests to fido2-utils.ts to ensure we maintain functionality for utils methods pulled from platform utils * [PM-5189] Addressing code review feedback * [PM-5744] Applying code review feedback, reworking obserable subscription within fido2 background * [PM-5744] Reworking jest tests to avoid mocking `firstValueFrom` * [PM-5744] Reworking jest tests to avoid usage of private methods * [PM-5744] Reworking jest tests to avoid usage of private methods * [PM-5744] Implementing jest tests for the ScriptInjectorService and updating references within the Browser Extension to use the new service * [PM-5744] Converting ScriptInjectorService to a dependnecy instead of a static class * [PM-5744] Reworking typing for the ScriptInjectorService * [PM-5744] Adjusting implementation based on feedback provided during code review * [PM-5744] Adjusting implementation based on feedback provided during code review * [PM-5744] Adjusting implementation based on feedback provided during code review * [PM-5744] Adjusting implementation based on feedback provided during code review * [PM-5744] Adjusting how the ScriptInjectorService accepts the config to simplify the data structure * [PM-5744] Updating jest tests to cover edge cases within ScriptInjectorService * [PM-5744] Updating jest tests to reference the ScriptInjectorService directly rather than the underlying ExecuteScript api call * [PM-5744] Updating jest tests to reflect provided feedback during code review * [PM-5744] Updating jest tests to reflect provided feedback during code review * [PM-5744] Updating documentation based on code review feedback * [PM-5744] Updating how we extend the abstract ScriptInjectorService * [PM-5744] Updating reference to the frame property on the ScriptInjectionConfig
2024-04-18 18:05:16 +02:00
/**
* MIT License
*
* Copyright (c) Federico Brigante <me@fregante.com> (https://fregante.com)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* @see https://github.com/fregante/content-scripts-register-polyfill
* @version 4.0.2
*/
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { BrowserApi } from "./browser-api";
let registerContentScripts: (
contentScriptOptions: browser.contentScripts.RegisteredContentScriptOptions,
callback?: (registeredContentScript: browser.contentScripts.RegisteredContentScript) => void,
) => Promise<browser.contentScripts.RegisteredContentScript>;
export async function registerContentScriptsPolyfill(
contentScriptOptions: browser.contentScripts.RegisteredContentScriptOptions,
callback?: (registeredContentScript: browser.contentScripts.RegisteredContentScript) => void,
) {
if (!registerContentScripts) {
registerContentScripts = buildRegisterContentScriptsPolyfill();
}
return registerContentScripts(contentScriptOptions, callback);
}
function buildRegisterContentScriptsPolyfill() {
const logService = new ConsoleLogService(false);
const chromeProxy = globalThis.chrome && NestedProxy<typeof globalThis.chrome>(globalThis.chrome);
const patternValidationRegex =
/^(https?|wss?|file|ftp|\*):\/\/(\*|\*\.[^*/]+|[^*/]+)\/.*$|^file:\/\/\/.*$|^resource:\/\/(\*|\*\.[^*/]+|[^*/]+)\/.*$|^about:/;
const isFirefox = globalThis.navigator?.userAgent.includes("Firefox/");
const gotScripting = Boolean(globalThis.chrome?.scripting);
const gotNavigation = typeof chrome === "object" && "webNavigation" in chrome;
function NestedProxy<T extends object>(target: T): T {
return new Proxy(target, {
get(target, prop) {
if (!target[prop as keyof T]) {
return;
}
if (typeof target[prop as keyof T] !== "function") {
return NestedProxy(target[prop as keyof T]);
}
return (...arguments_: any[]) =>
new Promise((resolve, reject) => {
target[prop as keyof T](...arguments_, (result: any) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(result);
}
});
});
},
});
}
function assertValidPattern(matchPattern: string) {
if (!isValidPattern(matchPattern)) {
throw new Error(
`${matchPattern} is an invalid pattern, it must match ${String(patternValidationRegex)}`,
);
}
}
function isValidPattern(matchPattern: string) {
return matchPattern === "<all_urls>" || patternValidationRegex.test(matchPattern);
}
function getRawPatternRegex(matchPattern: string) {
assertValidPattern(matchPattern);
let [, protocol, host = "", pathname] = matchPattern.split(/(^[^:]+:[/][/])([^/]+)?/);
protocol = protocol
.replace("*", isFirefox ? "(https?|wss?)" : "https?")
.replaceAll(/[/]/g, "[/]");
if (host === "*") {
host = "[^/]+";
} else if (host) {
host = host
.replace(/^[*][.]/, "([^/]+.)*")
.replaceAll(/[.]/g, "[.]")
.replace(/[*]$/, "[^.]+");
}
pathname = pathname
.replaceAll(/[/]/g, "[/]")
.replaceAll(/[.]/g, "[.]")
.replaceAll(/[*]/g, ".*");
return "^" + protocol + host + "(" + pathname + ")?$";
}
function patternToRegex(...matchPatterns: string[]) {
if (matchPatterns.length === 0) {
return /$./;
}
if (matchPatterns.includes("<all_urls>")) {
// <all_urls> regex
return /^(https?|file|ftp):[/]+/;
}
if (matchPatterns.includes("*://*/*")) {
// all stars regex
return isFirefox ? /^(https?|wss?):[/][/][^/]+([/].*)?$/ : /^https?:[/][/][^/]+([/].*)?$/;
}
return new RegExp(matchPatterns.map((x) => getRawPatternRegex(x)).join("|"));
}
function castAllFramesTarget(target: number | { tabId: number; frameId: number }) {
if (typeof target === "object") {
return { ...target, allFrames: false };
}
return {
tabId: target,
frameId: undefined,
allFrames: true,
};
}
function castArray(possibleArray: any | any[]) {
if (Array.isArray(possibleArray)) {
return possibleArray;
}
return [possibleArray];
}
function arrayOrUndefined(value?: number) {
return value === undefined ? undefined : [value];
}
async function insertCSS(
{
tabId,
frameId,
files,
allFrames,
matchAboutBlank,
runAt,
}: {
tabId: number;
frameId?: number;
files: browser.extensionTypes.ExtensionFileOrCode[];
allFrames: boolean;
matchAboutBlank: boolean;
runAt: browser.extensionTypes.RunAt;
},
{ ignoreTargetErrors }: { ignoreTargetErrors?: boolean } = {},
) {
const everyInsertion = Promise.all(
files.map(async (content) => {
if (typeof content === "string") {
content = { file: content };
}
if (gotScripting) {
return chrome.scripting.insertCSS({
target: {
tabId,
frameIds: arrayOrUndefined(frameId),
allFrames: frameId === undefined ? allFrames : undefined,
},
files: "file" in content ? [content.file] : undefined,
css: "code" in content ? content.code : undefined,
});
}
return chromeProxy.tabs.insertCSS(tabId, {
...content,
matchAboutBlank,
allFrames,
frameId,
runAt: runAt ?? "document_start",
});
}),
);
if (ignoreTargetErrors) {
await catchTargetInjectionErrors(everyInsertion);
} else {
await everyInsertion;
}
}
function assertNoCode(files: browser.extensionTypes.ExtensionFileOrCode[]) {
if (files.some((content) => "code" in content)) {
throw new Error("chrome.scripting does not support injecting strings of `code`");
}
}
async function executeScript(
{
tabId,
frameId,
files,
allFrames,
matchAboutBlank,
runAt,
}: {
tabId: number;
frameId?: number;
files: browser.extensionTypes.ExtensionFileOrCode[];
allFrames: boolean;
matchAboutBlank: boolean;
runAt: browser.extensionTypes.RunAt;
},
{ ignoreTargetErrors }: { ignoreTargetErrors?: boolean } = {},
) {
const normalizedFiles = files.map((file) => (typeof file === "string" ? { file } : file));
if (gotScripting) {
assertNoCode(normalizedFiles);
const injection = chrome.scripting.executeScript({
target: {
tabId,
frameIds: arrayOrUndefined(frameId),
allFrames: frameId === undefined ? allFrames : undefined,
},
files: normalizedFiles.map(({ file }: { file: string }) => file),
});
if (ignoreTargetErrors) {
await catchTargetInjectionErrors(injection);
} else {
await injection;
}
return;
}
const executions = [];
for (const content of normalizedFiles) {
if ("code" in content) {
await executions.at(-1);
}
executions.push(
chromeProxy.tabs.executeScript(tabId, {
...content,
matchAboutBlank,
allFrames,
frameId,
runAt,
}),
);
}
if (ignoreTargetErrors) {
await catchTargetInjectionErrors(Promise.all(executions));
} else {
await Promise.all(executions);
}
}
async function injectContentScript(
where: { tabId: number; frameId: number },
scripts: {
css: browser.extensionTypes.ExtensionFileOrCode[];
js: browser.extensionTypes.ExtensionFileOrCode[];
matchAboutBlank: boolean;
runAt: browser.extensionTypes.RunAt;
},
options = {},
) {
const targets = castArray(where);
await Promise.all(
targets.map(async (target) =>
injectContentScriptInSpecificTarget(castAllFramesTarget(target), scripts, options),
),
);
}
async function injectContentScriptInSpecificTarget(
{ frameId, tabId, allFrames }: { frameId?: number; tabId: number; allFrames: boolean },
scripts: {
css: browser.extensionTypes.ExtensionFileOrCode[];
js: browser.extensionTypes.ExtensionFileOrCode[];
matchAboutBlank: boolean;
runAt: browser.extensionTypes.RunAt;
},
options = {},
) {
const injections = castArray(scripts).flatMap((script) => [
insertCSS(
{
tabId,
frameId,
allFrames,
files: script.css ?? [],
matchAboutBlank: script.matchAboutBlank ?? script.match_about_blank,
runAt: script.runAt ?? script.run_at,
},
options,
),
executeScript(
{
tabId,
frameId,
allFrames,
files: script.js ?? [],
matchAboutBlank: script.matchAboutBlank ?? script.match_about_blank,
runAt: script.runAt ?? script.run_at,
},
options,
),
]);
await Promise.all(injections);
}
async function catchTargetInjectionErrors(promise: Promise<any>) {
try {
await promise;
} catch (error) {
const targetErrors =
/^No frame with id \d+ in tab \d+.$|^No tab with id: \d+.$|^The tab was closed.$|^The frame was removed.$/;
if (!targetErrors.test(error?.message)) {
throw error;
}
}
}
async function isOriginPermitted(url: string) {
return chromeProxy.permissions.contains({
origins: [new URL(url).origin + "/*"],
});
}
return async (
contentScriptOptions: browser.contentScripts.RegisteredContentScriptOptions,
callback: CallableFunction,
) => {
const {
js = [],
css = [],
matchAboutBlank,
matches = [],
excludeMatches,
runAt,
} = contentScriptOptions;
let { allFrames } = contentScriptOptions;
if (gotNavigation) {
allFrames = false;
} else if (allFrames) {
logService.warning(
"`allFrames: true` requires the `webNavigation` permission to work correctly: https://github.com/fregante/content-scripts-register-polyfill#permissions",
);
}
if (matches.length === 0) {
throw new Error(
"Type error for parameter contentScriptOptions (Error processing matches: Array requires at least 1 items; you have 0) for contentScripts.register.",
);
}
await Promise.all(
matches.map(async (pattern: string) => {
if (!(await chromeProxy.permissions.contains({ origins: [pattern] }))) {
throw new Error(`Permission denied to register a content script for ${pattern}`);
}
}),
);
const matchesRegex = patternToRegex(...matches);
const excludeMatchesRegex = patternToRegex(
...(excludeMatches !== null && excludeMatches !== void 0 ? excludeMatches : []),
);
const inject = async (url: string, tabId: number, frameId = 0) => {
if (
!matchesRegex.test(url) ||
excludeMatchesRegex.test(url) ||
!(await isOriginPermitted(url))
) {
return;
}
await injectContentScript(
{ tabId, frameId },
{ css, js, matchAboutBlank, runAt },
{ ignoreTargetErrors: true },
);
};
const tabListener = async (
tabId: number,
{ status }: chrome.tabs.TabChangeInfo,
{ url }: chrome.tabs.Tab,
) => {
if (status === "loading" && url) {
void inject(url, tabId);
}
};
const navListener = async ({
tabId,
frameId,
url,
}: chrome.webNavigation.WebNavigationTransitionCallbackDetails) => {
void inject(url, tabId, frameId);
};
if (gotNavigation) {
BrowserApi.addListener(chrome.webNavigation.onCommitted, navListener);
} else {
BrowserApi.addListener(chrome.tabs.onUpdated, tabListener);
}
const registeredContentScript = {
async unregister() {
if (gotNavigation) {
chrome.webNavigation.onCommitted.removeListener(navListener);
} else {
chrome.tabs.onUpdated.removeListener(tabListener);
}
},
};
if (typeof callback === "function") {
callback(registeredContentScript);
}
return registeredContentScript;
};
}