[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
This commit is contained in:
Cesar Gonzalez 2024-04-18 11:05:16 -05:00 committed by GitHub
parent 912b7c136e
commit 9277465951
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 2767 additions and 564 deletions

View File

@ -7,6 +7,10 @@ import {
eventCollectionServiceFactory,
} from "../../../background/service-factories/event-collection-service.factory";
import { billingAccountProfileStateServiceFactory } from "../../../platform/background/service-factories/billing-account-profile-state-service.factory";
import {
browserScriptInjectorServiceFactory,
BrowserScriptInjectorServiceInitOptions,
} from "../../../platform/background/service-factories/browser-script-injector-service.factory";
import {
CachedServices,
factory,
@ -45,7 +49,8 @@ export type AutoFillServiceInitOptions = AutoFillServiceOptions &
EventCollectionServiceInitOptions &
LogServiceInitOptions &
UserVerificationServiceInitOptions &
DomainSettingsServiceInitOptions;
DomainSettingsServiceInitOptions &
BrowserScriptInjectorServiceInitOptions;
export function autofillServiceFactory(
cache: { autofillService?: AbstractAutoFillService } & CachedServices,
@ -65,6 +70,7 @@ export function autofillServiceFactory(
await domainSettingsServiceFactory(cache, opts),
await userVerificationServiceFactory(cache, opts),
await billingAccountProfileStateServiceFactory(cache, opts),
await browserScriptInjectorServiceFactory(cache, opts),
),
);
}

View File

@ -560,6 +560,17 @@ describe("AutofillInit", () => {
});
describe("destroy", () => {
it("clears the timeout used to collect page details on load", () => {
jest.spyOn(window, "clearTimeout");
autofillInit.init();
autofillInit.destroy();
expect(window.clearTimeout).toHaveBeenCalledWith(
autofillInit["collectPageDetailsOnLoadTimeout"],
);
});
it("removes the extension message listeners", () => {
autofillInit.destroy();

View File

@ -16,6 +16,7 @@ class AutofillInit implements AutofillInitInterface {
private readonly domElementVisibilityService: DomElementVisibilityService;
private readonly collectAutofillContentService: CollectAutofillContentService;
private readonly insertAutofillContentService: InsertAutofillContentService;
private collectPageDetailsOnLoadTimeout: number | NodeJS.Timeout | undefined;
private readonly extensionMessageHandlers: AutofillExtensionMessageHandlers = {
collectPageDetails: ({ message }) => this.collectPageDetails(message),
collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true),
@ -66,17 +67,19 @@ class AutofillInit implements AutofillInitInterface {
* to act on the page.
*/
private collectPageDetailsOnLoad() {
const sendCollectDetailsMessage = () =>
setTimeout(
const sendCollectDetailsMessage = () => {
this.clearCollectPageDetailsOnLoadTimeout();
this.collectPageDetailsOnLoadTimeout = setTimeout(
() => sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
250,
);
};
if (document.readyState === "complete") {
if (globalThis.document.readyState === "complete") {
sendCollectDetailsMessage();
}
window.addEventListener("load", sendCollectDetailsMessage);
globalThis.addEventListener("load", sendCollectDetailsMessage);
}
/**
@ -247,6 +250,15 @@ class AutofillInit implements AutofillInitInterface {
this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility;
}
/**
* Clears the send collect details message timeout.
*/
private clearCollectPageDetailsOnLoadTimeout() {
if (this.collectPageDetailsOnLoadTimeout) {
clearTimeout(this.collectPageDetailsOnLoadTimeout);
}
}
/**
* Sets up the extension message listeners for the content script.
*/
@ -288,6 +300,7 @@ class AutofillInit implements AutofillInitInterface {
* listeners, timeouts, and object instances to prevent memory leaks.
*/
destroy() {
this.clearCollectPageDetailsOnLoadTimeout();
chrome.runtime.onMessage.removeListener(this.handleExtensionMessage);
this.collectAutofillContentService.destroy();
this.autofillOverlayContentService?.destroy();

View File

@ -32,6 +32,7 @@ import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
import { AutofillPort } from "../enums/autofill-port.enums";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
@ -67,6 +68,7 @@ describe("AutofillService", () => {
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService);
let domainSettingsService: DomainSettingsService;
let scriptInjectorService: BrowserScriptInjectorService;
const totpService = mock<TotpService>();
const eventCollectionService = mock<EventCollectionService>();
const logService = mock<LogService>();
@ -74,6 +76,7 @@ describe("AutofillService", () => {
const billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService();
autofillService = new AutofillService(
cipherService,
autofillSettingsService,
@ -83,6 +86,7 @@ describe("AutofillService", () => {
domainSettingsService,
userVerificationService,
billingAccountProfileStateService,
scriptInjectorService,
);
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
@ -250,6 +254,7 @@ describe("AutofillService", () => {
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: "content/content-message-handler.js",
frameId: 0,
...defaultExecuteScriptOptions,
});
});

View File

@ -20,6 +20,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import { BrowserApi } from "../../platform/browser/browser-api";
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
import { AutofillPort } from "../enums/autofill-port.enums";
import AutofillField from "../models/autofill-field";
@ -55,6 +56,7 @@ export default class AutofillService implements AutofillServiceInterface {
private domainSettingsService: DomainSettingsService,
private userVerificationService: UserVerificationService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private scriptInjectorService: ScriptInjectorService,
) {}
/**
@ -113,19 +115,22 @@ export default class AutofillService implements AutofillServiceInterface {
if (triggeringOnPageLoad && autoFillOnPageLoadIsEnabled) {
injectedScripts.push("autofiller.js");
} else {
await BrowserApi.executeScriptInTab(tab.id, {
file: "content/content-message-handler.js",
runAt: "document_start",
await this.scriptInjectorService.inject({
tabId: tab.id,
injectDetails: { file: "content/content-message-handler.js", runAt: "document_start" },
});
}
injectedScripts.push("notificationBar.js", "contextMenuHandler.js");
for (const injectedScript of injectedScripts) {
await BrowserApi.executeScriptInTab(tab.id, {
file: `content/${injectedScript}`,
frameId,
runAt: "document_start",
await this.scriptInjectorService.inject({
tabId: tab.id,
injectDetails: {
file: `content/${injectedScript}`,
runAt: "document_start",
frame: frameId,
},
});
}
}

View File

@ -267,6 +267,7 @@ function createPortSpyMock(name: string) {
disconnect: jest.fn(),
sender: {
tab: createChromeTabMock(),
url: "https://jest-testing-website.com",
},
});
}

View File

@ -0,0 +1,74 @@
import { mock } from "jest-mock-extended";
import {
AssertCredentialResult,
CreateCredentialResult,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
export function createCredentialCreationOptionsMock(
customFields: Partial<CredentialCreationOptions> = {},
): CredentialCreationOptions {
return mock<CredentialCreationOptions>({
publicKey: mock<PublicKeyCredentialCreationOptions>({
authenticatorSelection: { authenticatorAttachment: "platform" },
excludeCredentials: [{ id: new ArrayBuffer(32), type: "public-key" }],
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
user: { id: new ArrayBuffer(32), name: "test", displayName: "test" },
}),
...customFields,
});
}
export function createCreateCredentialResultMock(
customFields: Partial<CreateCredentialResult> = {},
): CreateCredentialResult {
return mock<CreateCredentialResult>({
credentialId: "mock",
clientDataJSON: "mock",
attestationObject: "mock",
authData: "mock",
publicKey: "mock",
publicKeyAlgorithm: -7,
transports: ["internal"],
...customFields,
});
}
export function createCredentialRequestOptionsMock(
customFields: Partial<CredentialRequestOptions> = {},
): CredentialRequestOptions {
return mock<CredentialRequestOptions>({
mediation: "optional",
publicKey: mock<PublicKeyCredentialRequestOptions>({
allowCredentials: [{ id: new ArrayBuffer(32), type: "public-key" }],
}),
...customFields,
});
}
export function createAssertCredentialResultMock(
customFields: Partial<AssertCredentialResult> = {},
): AssertCredentialResult {
return mock<AssertCredentialResult>({
credentialId: "mock",
clientDataJSON: "mock",
authenticatorData: "mock",
signature: "mock",
userHandle: "mock",
...customFields,
});
}
export function setupMockedWebAuthnSupport() {
(globalThis as any).PublicKeyCredential = class PolyfillPublicKeyCredential {
static isUserVerifyingPlatformAuthenticatorAvailable = () => Promise.resolve(true);
};
(globalThis as any).AuthenticatorAttestationResponse =
class PolyfillAuthenticatorAttestationResponse {};
(globalThis as any).AuthenticatorAssertionResponse =
class PolyfillAuthenticatorAssertionResponse {};
(globalThis as any).navigator.credentials = {
create: jest.fn().mockResolvedValue({}),
get: jest.fn().mockResolvedValue({}),
};
}

View File

@ -214,6 +214,7 @@ import BrowserLocalStorageService from "../platform/services/browser-local-stora
import BrowserMemoryStorageService from "../platform/services/browser-memory-storage.service";
import BrowserMessagingPrivateModeBackgroundService from "../platform/services/browser-messaging-private-mode-background.service";
import BrowserMessagingService from "../platform/services/browser-messaging.service";
import { BrowserScriptInjectorService } from "../platform/services/browser-script-injector.service";
import { DefaultBrowserStateService } from "../platform/services/default-browser-state.service";
import I18nService from "../platform/services/i18n.service";
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
@ -223,9 +224,9 @@ import { BackgroundDerivedStateProvider } from "../platform/state/background-der
import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
import FilelessImporterBackground from "../tools/background/fileless-importer.background";
import { Fido2Background as Fido2BackgroundAbstraction } from "../vault/fido2/background/abstractions/fido2.background";
import { Fido2Background } from "../vault/fido2/background/fido2.background";
import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service";
import { Fido2Service as Fido2ServiceAbstraction } from "../vault/services/abstractions/fido2.service";
import Fido2Service from "../vault/services/fido2.service";
import { VaultFilterService } from "../vault/services/vault-filter.service";
import CommandsBackground from "./commands.background";
@ -316,7 +317,7 @@ export default class MainBackground {
activeUserStateProvider: ActiveUserStateProvider;
derivedStateProvider: DerivedStateProvider;
stateProvider: StateProvider;
fido2Service: Fido2ServiceAbstraction;
fido2Background: Fido2BackgroundAbstraction;
individualVaultExportService: IndividualVaultExportServiceAbstraction;
organizationVaultExportService: OrganizationVaultExportServiceAbstraction;
vaultSettingsService: VaultSettingsServiceAbstraction;
@ -324,6 +325,7 @@ export default class MainBackground {
stateEventRunnerService: StateEventRunnerService;
ssoLoginService: SsoLoginServiceAbstraction;
billingAccountProfileStateService: BillingAccountProfileStateService;
scriptInjectorService: BrowserScriptInjectorService;
onUpdatedRan: boolean;
onReplacedRan: boolean;
@ -791,6 +793,7 @@ export default class MainBackground {
);
this.totpService = new TotpService(this.cryptoFunctionService, this.logService);
this.scriptInjectorService = new BrowserScriptInjectorService();
this.autofillService = new AutofillService(
this.cipherService,
this.autofillSettingsService,
@ -800,6 +803,7 @@ export default class MainBackground {
this.domainSettingsService,
this.userVerificationService,
this.billingAccountProfileStateService,
this.scriptInjectorService,
);
this.auditService = new AuditService(this.cryptoFunctionService, this.apiService);
@ -849,7 +853,6 @@ export default class MainBackground {
this.messagingService,
);
this.fido2Service = new Fido2Service();
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
this.fido2AuthenticatorService = new Fido2AuthenticatorService(
this.cipherService,
@ -890,11 +893,16 @@ export default class MainBackground {
// Background
if (!this.popupOnlyContext) {
this.fido2Background = new Fido2Background(
this.logService,
this.fido2ClientService,
this.vaultSettingsService,
this.scriptInjectorService,
);
this.runtimeBackground = new RuntimeBackground(
this,
this.autofillService,
this.platformUtilsService as BrowserPlatformUtilsService,
this.i18nService,
this.notificationsService,
this.stateService,
this.autofillSettingsService,
@ -903,7 +911,7 @@ export default class MainBackground {
this.messagingService,
this.logService,
this.configService,
this.fido2Service,
this.fido2Background,
);
this.nativeMessagingBackground = new NativeMessagingBackground(
this.accountService,
@ -959,6 +967,7 @@ export default class MainBackground {
this.notificationBackground,
this.importService,
this.syncService,
this.scriptInjectorService,
);
this.tabsBackground = new TabsBackground(
this,
@ -1045,11 +1054,12 @@ export default class MainBackground {
await this.stateService.init({ runMigrations: !this.isPrivateMode });
await (this.i18nService as I18nService).init();
(this.eventUploadService as EventUploadService).init(true);
await (this.eventUploadService as EventUploadService).init(true);
this.twoFactorService.init();
if (!this.popupOnlyContext) {
await this.vaultTimeoutService.init(true);
this.fido2Background.init();
await this.runtimeBackground.init();
await this.notificationBackground.init();
this.filelessImporterBackground.init();

View File

@ -4,7 +4,6 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notificatio
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
@ -22,8 +21,7 @@ import { BrowserApi } from "../platform/browser/browser-api";
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
import { AbortManager } from "../vault/background/abort-manager";
import { Fido2Service } from "../vault/services/abstractions/fido2.service";
import { Fido2Background } from "../vault/fido2/background/abstractions/fido2.background";
import MainBackground from "./main.background";
@ -32,13 +30,11 @@ export default class RuntimeBackground {
private pageDetailsToAutoFill: any[] = [];
private onInstalledReason: string = null;
private lockedVaultPendingNotifications: LockedVaultPendingNotificationsData[] = [];
private abortManager = new AbortManager();
constructor(
private main: MainBackground,
private autofillService: AutofillService,
private platformUtilsService: BrowserPlatformUtilsService,
private i18nService: I18nService,
private notificationsService: NotificationsService,
private stateService: BrowserStateService,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
@ -47,7 +43,7 @@ export default class RuntimeBackground {
private messagingService: MessagingService,
private logService: LogService,
private configService: ConfigService,
private fido2Service: Fido2Service,
private fido2Background: Fido2Background,
) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => {
@ -66,12 +62,7 @@ export default class RuntimeBackground {
sender: chrome.runtime.MessageSender,
sendResponse: any,
) => {
const messagesWithResponse = [
"checkFido2FeatureEnabled",
"fido2RegisterCredentialRequest",
"fido2GetCredentialRequest",
"biometricUnlock",
];
const messagesWithResponse = ["biometricUnlock"];
if (messagesWithResponse.includes(msg.command)) {
this.processMessage(msg, sender).then(
@ -81,10 +72,7 @@ export default class RuntimeBackground {
return true;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.processMessage(msg, sender);
return false;
this.processMessage(msg, sender).catch((e) => this.logService.error(e));
};
BrowserApi.messageListener("runtime.background", backgroundMessageListener);
@ -269,46 +257,6 @@ export default class RuntimeBackground {
case "getClickedElementResponse":
this.platformUtilsService.copyToClipboard(msg.identifier);
break;
case "triggerFido2ContentScriptInjection":
await this.fido2Service.injectFido2ContentScripts(sender);
break;
case "fido2AbortRequest":
this.abortManager.abort(msg.abortedRequestId);
break;
case "checkFido2FeatureEnabled":
return await this.main.fido2ClientService.isFido2FeatureEnabled(msg.hostname, msg.origin);
case "fido2RegisterCredentialRequest":
return await this.abortManager.runWithAbortController(
msg.requestId,
async (abortController) => {
try {
return await this.main.fido2ClientService.createCredential(
msg.data,
sender.tab,
abortController,
);
} finally {
await BrowserApi.focusTab(sender.tab.id);
await BrowserApi.focusWindow(sender.tab.windowId);
}
},
);
case "fido2GetCredentialRequest":
return await this.abortManager.runWithAbortController(
msg.requestId,
async (abortController) => {
try {
return await this.main.fido2ClientService.assertCredential(
msg.data,
sender.tab,
abortController,
);
} finally {
await BrowserApi.focusTab(sender.tab.id);
await BrowserApi.focusWindow(sender.tab.windowId);
}
},
);
case "switchAccount": {
await this.main.switchAccount(msg.userId);
break;
@ -343,9 +291,8 @@ export default class RuntimeBackground {
private async checkOnInstalled() {
setTimeout(async () => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.autofillService.loadAutofillScriptsOnInstall();
void this.fido2Background.injectFido2ContentScriptsInAllTabs();
void this.autofillService.loadAutofillScriptsOnInstall();
if (this.onInstalledReason != null) {
if (this.onInstalledReason === "install") {

View File

@ -22,13 +22,6 @@
"exclude_matches": ["*://*/*.xml*", "file:///*.xml*"],
"run_at": "document_start"
},
{
"all_frames": true,
"js": ["content/fido2/trigger-fido2-content-script-injection.js"],
"matches": ["https://*/*"],
"exclude_matches": ["https://*/*.xml*"],
"run_at": "document_start"
},
{
"all_frames": true,
"css": ["content/autofill.css"],
@ -67,7 +60,8 @@
"clipboardWrite",
"idle",
"webRequest",
"webRequestBlocking"
"webRequestBlocking",
"webNavigation"
],
"optional_permissions": ["nativeMessaging", "privacy"],
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",

View File

@ -23,13 +23,6 @@
"exclude_matches": ["*://*/*.xml*", "file:///*.xml*"],
"run_at": "document_start"
},
{
"all_frames": true,
"js": ["content/fido2/trigger-fido2-content-script-injection.js"],
"matches": ["https://*/*"],
"exclude_matches": ["https://*/*.xml*"],
"run_at": "document_start"
},
{
"all_frames": true,
"css": ["content/autofill.css"],

View File

@ -0,0 +1,19 @@
import { BrowserScriptInjectorService } from "../../services/browser-script-injector.service";
import { CachedServices, FactoryOptions, factory } from "./factory-options";
type BrowserScriptInjectorServiceOptions = FactoryOptions;
export type BrowserScriptInjectorServiceInitOptions = BrowserScriptInjectorServiceOptions;
export function browserScriptInjectorServiceFactory(
cache: { browserScriptInjectorService?: BrowserScriptInjectorService } & CachedServices,
opts: BrowserScriptInjectorServiceInitOptions,
): Promise<BrowserScriptInjectorService> {
return factory(
cache,
"browserScriptInjectorService",
opts,
async () => new BrowserScriptInjectorService(),
);
}

View File

@ -0,0 +1,435 @@
/**
* 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;
};
}

View File

@ -550,4 +550,35 @@ describe("BrowserApi", () => {
expect(callbackMock).toHaveBeenCalled();
});
});
describe("registerContentScriptsMv2", () => {
const details: browser.contentScripts.RegisteredContentScriptOptions = {
matches: ["<all_urls>"],
js: [{ file: "content/fido2/page-script.js" }],
};
it("registers content scripts through the `browser.contentScripts` API when the API is available", async () => {
globalThis.browser = mock<typeof browser>({
contentScripts: { register: jest.fn() },
});
await BrowserApi.registerContentScriptsMv2(details);
expect(browser.contentScripts.register).toHaveBeenCalledWith(details);
});
it("registers content scripts through the `registerContentScriptsPolyfill` when the `browser.contentScripts.register` API is not available", async () => {
globalThis.browser = mock<typeof browser>({
contentScripts: { register: undefined },
});
jest.spyOn(BrowserApi, "addListener");
await BrowserApi.registerContentScriptsMv2(details);
expect(BrowserApi.addListener).toHaveBeenCalledWith(
chrome.webNavigation.onCommitted,
expect.any(Function),
);
});
});
});

View File

@ -5,6 +5,8 @@ import { DeviceType } from "@bitwarden/common/enums";
import { TabMessage } from "../../types/tab-messages";
import { BrowserPlatformUtilsService } from "../services/platform-utils/browser-platform-utils.service";
import { registerContentScriptsPolyfill } from "./browser-api.register-content-scripts-polyfill";
export class BrowserApi {
static isWebExtensionsApi: boolean = typeof browser !== "undefined";
static isSafariApi: boolean =
@ -591,4 +593,41 @@ export class BrowserApi {
}
});
}
/**
* Handles registration of static content scripts within manifest v2.
*
* @param contentScriptOptions - Details of the registered content scripts
*/
static async registerContentScriptsMv2(
contentScriptOptions: browser.contentScripts.RegisteredContentScriptOptions,
): Promise<browser.contentScripts.RegisteredContentScript> {
if (typeof browser !== "undefined" && !!browser.contentScripts?.register) {
return await browser.contentScripts.register(contentScriptOptions);
}
return await registerContentScriptsPolyfill(contentScriptOptions);
}
/**
* Handles registration of static content scripts within manifest v3.
*
* @param scripts - Details of the registered content scripts
*/
static async registerContentScriptsMv3(
scripts: chrome.scripting.RegisteredContentScript[],
): Promise<void> {
await chrome.scripting.registerContentScripts(scripts);
}
/**
* Handles unregistering of static content scripts within manifest v3.
*
* @param filter - Optional filter to unregister content scripts. Passing an empty object will unregister all content scripts.
*/
static async unregisterContentScriptsMv3(
filter?: chrome.scripting.ContentScriptFilter,
): Promise<void> {
await chrome.scripting.unregisterContentScripts(filter);
}
}

View File

@ -0,0 +1,45 @@
export type CommonScriptInjectionDetails = {
/**
* Script injected into the document.
* Overridden by `mv2Details` and `mv3Details`.
*/
file?: string;
/**
* Identifies the frame targeted for script injection. Defaults to the top level frame (0).
* Can also be set to "all_frames" to inject into all frames in a tab.
*/
frame?: "all_frames" | number;
/**
* When the script executes. Defaults to "document_start".
* @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/content_scripts
*/
runAt?: "document_start" | "document_end" | "document_idle";
};
export type Mv2ScriptInjectionDetails = {
file: string;
};
export type Mv3ScriptInjectionDetails = {
file: string;
/**
* The world in which the script should be executed. Defaults to "ISOLATED".
* @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld
*/
world?: chrome.scripting.ExecutionWorld;
};
/**
* Configuration for injecting a script into a tab. The `file` property should present as a
* path that is relative to the root directory of the extension build, ie "content/script.js".
*/
export type ScriptInjectionConfig = {
tabId: number;
injectDetails: CommonScriptInjectionDetails;
mv2Details?: Mv2ScriptInjectionDetails;
mv3Details?: Mv3ScriptInjectionDetails;
};
export abstract class ScriptInjectorService {
abstract inject(config: ScriptInjectionConfig): Promise<void>;
}

View File

@ -0,0 +1,173 @@
import { BrowserApi } from "../browser/browser-api";
import {
CommonScriptInjectionDetails,
Mv3ScriptInjectionDetails,
} from "./abstractions/script-injector.service";
import { BrowserScriptInjectorService } from "./browser-script-injector.service";
describe("ScriptInjectorService", () => {
const tabId = 1;
const combinedManifestVersionFile = "content/autofill-init.js";
const mv2SpecificFile = "content/autofill-init-mv2.js";
const mv2Details = { file: mv2SpecificFile };
const mv3SpecificFile = "content/autofill-init-mv3.js";
const mv3Details: Mv3ScriptInjectionDetails = { file: mv3SpecificFile, world: "MAIN" };
const sharedInjectDetails: CommonScriptInjectionDetails = {
runAt: "document_start",
};
const manifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get");
let scriptInjectorService: BrowserScriptInjectorService;
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
jest.spyOn(BrowserApi, "isManifestVersion");
beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService();
});
describe("inject", () => {
describe("injection of a single script that functions in both manifest v2 and v3", () => {
it("injects the script in manifest v2 when given combined injection details", async () => {
manifestVersionSpy.mockReturnValue(2);
await scriptInjectorService.inject({
tabId,
injectDetails: {
file: combinedManifestVersionFile,
frame: "all_frames",
...sharedInjectDetails,
},
});
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabId, {
...sharedInjectDetails,
allFrames: true,
file: combinedManifestVersionFile,
});
});
it("injects the script in manifest v3 when given combined injection details", async () => {
manifestVersionSpy.mockReturnValue(3);
await scriptInjectorService.inject({
tabId,
injectDetails: {
file: combinedManifestVersionFile,
frame: 10,
...sharedInjectDetails,
},
});
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(
tabId,
{ ...sharedInjectDetails, frameId: 10, file: combinedManifestVersionFile },
{ world: "ISOLATED" },
);
});
});
describe("injection of mv2 specific details", () => {
describe("given the extension is running manifest v2", () => {
it("injects the mv2 script injection details file", async () => {
manifestVersionSpy.mockReturnValue(2);
await scriptInjectorService.inject({
mv2Details,
tabId,
injectDetails: sharedInjectDetails,
});
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabId, {
...sharedInjectDetails,
frameId: 0,
file: mv2SpecificFile,
});
});
});
describe("given the extension is running manifest v3", () => {
it("injects the common script injection details file", async () => {
manifestVersionSpy.mockReturnValue(3);
await scriptInjectorService.inject({
mv2Details,
tabId,
injectDetails: { ...sharedInjectDetails, file: combinedManifestVersionFile },
});
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(
tabId,
{
...sharedInjectDetails,
frameId: 0,
file: combinedManifestVersionFile,
},
{ world: "ISOLATED" },
);
});
it("throws an error if no common script injection details file is specified", async () => {
manifestVersionSpy.mockReturnValue(3);
await expect(
scriptInjectorService.inject({
mv2Details,
tabId,
injectDetails: { ...sharedInjectDetails, file: null },
}),
).rejects.toThrow("No file specified for script injection");
});
});
});
describe("injection of mv3 specific details", () => {
describe("given the extension is running manifest v3", () => {
it("injects the mv3 script injection details file", async () => {
manifestVersionSpy.mockReturnValue(3);
await scriptInjectorService.inject({
mv3Details,
tabId,
injectDetails: sharedInjectDetails,
});
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(
tabId,
{ ...sharedInjectDetails, frameId: 0, file: mv3SpecificFile },
{ world: "MAIN" },
);
});
});
describe("given the extension is running manifest v2", () => {
it("injects the common script injection details file", async () => {
manifestVersionSpy.mockReturnValue(2);
await scriptInjectorService.inject({
mv3Details,
tabId,
injectDetails: { ...sharedInjectDetails, file: combinedManifestVersionFile },
});
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabId, {
...sharedInjectDetails,
frameId: 0,
file: combinedManifestVersionFile,
});
});
it("throws an error if no common script injection details file is specified", async () => {
manifestVersionSpy.mockReturnValue(2);
await expect(
scriptInjectorService.inject({
mv3Details,
tabId,
injectDetails: { ...sharedInjectDetails, file: "" },
}),
).rejects.toThrow("No file specified for script injection");
});
});
});
});
});

View File

@ -0,0 +1,78 @@
import { BrowserApi } from "../browser/browser-api";
import {
CommonScriptInjectionDetails,
ScriptInjectionConfig,
ScriptInjectorService,
} from "./abstractions/script-injector.service";
export class BrowserScriptInjectorService extends ScriptInjectorService {
/**
* Facilitates the injection of a script into a tab context. Will adjust
* behavior between manifest v2 and v3 based on the passed configuration.
*
* @param config - The configuration for the script injection.
*/
async inject(config: ScriptInjectionConfig): Promise<void> {
const { tabId, injectDetails, mv3Details } = config;
const file = this.getScriptFile(config);
if (!file) {
throw new Error("No file specified for script injection");
}
const injectionDetails = this.buildInjectionDetails(injectDetails, file);
if (BrowserApi.isManifestVersion(3)) {
await BrowserApi.executeScriptInTab(tabId, injectionDetails, {
world: mv3Details?.world ?? "ISOLATED",
});
return;
}
await BrowserApi.executeScriptInTab(tabId, injectionDetails);
}
/**
* Retrieves the script file to inject based on the configuration.
*
* @param config - The configuration for the script injection.
*/
private getScriptFile(config: ScriptInjectionConfig): string {
const { injectDetails, mv2Details, mv3Details } = config;
if (BrowserApi.isManifestVersion(3)) {
return mv3Details?.file ?? injectDetails?.file;
}
return mv2Details?.file ?? injectDetails?.file;
}
/**
* Builds the injection details for the script injection.
*
* @param injectDetails - The details for the script injection.
* @param file - The file to inject.
*/
private buildInjectionDetails(
injectDetails: CommonScriptInjectionDetails,
file: string,
): chrome.tabs.InjectDetails {
const { frame, runAt } = injectDetails;
const injectionDetails: chrome.tabs.InjectDetails = { file };
if (runAt) {
injectionDetails.runAt = runAt;
}
if (!frame) {
return { ...injectionDetails, frameId: 0 };
}
if (frame !== "all_frames") {
return { ...injectionDetails, frameId: frame };
}
return { ...injectionDetails, allFrames: true };
}
}

View File

@ -94,10 +94,12 @@ import { BrowserApi } from "../../platform/browser/browser-api";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { BrowserFileDownloadService } from "../../platform/popup/services/browser-file-download.service";
import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service";
import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service";
import BrowserMessagingService from "../../platform/services/browser-messaging.service";
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
import { DefaultBrowserStateService } from "../../platform/services/default-browser-state.service";
import I18nService from "../../platform/services/i18n.service";
import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service";
@ -319,8 +321,14 @@ const safeProviders: SafeProvider[] = [
DomainSettingsService,
UserVerificationService,
BillingAccountProfileStateService,
ScriptInjectorService,
],
}),
safeProvider({
provide: ScriptInjectorService,
useClass: BrowserScriptInjectorService,
deps: [],
}),
safeProvider({
provide: KeyConnectorService,
useFactory: getBgService<KeyConnectorService>("keyConnectorService"),

View File

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

View File

@ -16,6 +16,7 @@ import {
triggerRuntimeOnConnectEvent,
} from "../../autofill/spec/testing-utils";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import.enums";
import FilelessImporterBackground from "./fileless-importer.background";
@ -37,8 +38,10 @@ describe("FilelessImporterBackground ", () => {
const notificationBackground = mock<NotificationBackground>();
const importService = mock<ImportServiceAbstraction>();
const syncService = mock<SyncService>();
let scriptInjectorService: BrowserScriptInjectorService;
beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService();
filelessImporterBackground = new FilelessImporterBackground(
configService,
authService,
@ -46,6 +49,7 @@ describe("FilelessImporterBackground ", () => {
notificationBackground,
importService,
syncService,
scriptInjectorService,
);
filelessImporterBackground.init();
});
@ -138,7 +142,7 @@ describe("FilelessImporterBackground ", () => {
expect(executeScriptInTabSpy).toHaveBeenCalledWith(
lpImporterPort.sender.tab.id,
{ file: "content/lp-suppress-import-download.js", runAt: "document_start" },
{ file: "content/lp-suppress-import-download.js", runAt: "document_start", frameId: 0 },
{ world: "MAIN" },
);
});
@ -149,14 +153,11 @@ describe("FilelessImporterBackground ", () => {
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,
);
expect(executeScriptInTabSpy).toHaveBeenCalledWith(lpImporterPort.sender.tab.id, {
file: "content/lp-suppress-import-download-script-append-mv2.js",
runAt: "document_start",
frameId: 0,
});
});
});

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 { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
import { FilelessImporterInjectedScriptsConfig } from "../config/fileless-importer-injected-scripts";
import {
FilelessImportPort,
@ -23,7 +24,6 @@ import {
LpImporterMessageHandlers,
FilelessImporterBackground as FilelessImporterBackgroundInterface,
FilelessImportPortMessage,
SuppressDownloadScriptInjectionConfig,
} from "./abstractions/fileless-importer.background";
class FilelessImporterBackground implements FilelessImporterBackgroundInterface {
@ -53,6 +53,7 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
* @param notificationBackground - Used to inject the notification bar into the tab.
* @param importService - Used to import the export data into the vault.
* @param syncService - Used to trigger a full sync after the import is completed.
* @param scriptInjectorService - Used to inject content scripts that initialize the import process
*/
constructor(
private configService: ConfigService,
@ -61,6 +62,7 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
private notificationBackground: NotificationBackground,
private importService: ImportServiceAbstraction,
private syncService: SyncService,
private scriptInjectorService: ScriptInjectorService,
) {}
/**
@ -110,23 +112,6 @@ 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.
@ -219,12 +204,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,
);
await this.scriptInjectorService.inject({
tabId: port.sender.tab.id,
injectDetails: { runAt: "document_start" },
mv2Details: FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv2,
mv3Details: FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv3,
});
break;
case FilelessImportPort.NotificationBar:
this.importNotificationsPort = port;

View File

@ -1,9 +1,12 @@
import { SuppressDownloadScriptInjectionConfig } from "../background/abstractions/fileless-importer.background";
import {
Mv2ScriptInjectionDetails,
Mv3ScriptInjectionDetails,
} from "../../platform/services/abstractions/script-injector.service";
type FilelessImporterInjectedScriptsConfigurations = {
LpSuppressImportDownload: {
mv2: SuppressDownloadScriptInjectionConfig;
mv3: SuppressDownloadScriptInjectionConfig;
mv2: Mv2ScriptInjectionDetails;
mv3: Mv3ScriptInjectionDetails;
};
};
@ -14,7 +17,7 @@ const FilelessImporterInjectedScriptsConfig: FilelessImporterInjectedScriptsConf
},
mv3: {
file: "content/lp-suppress-import-download.js",
scriptingApiDetails: { world: "MAIN" },
world: "MAIN",
},
},
} as const;

View File

@ -0,0 +1,57 @@
import {
AssertCredentialParams,
AssertCredentialResult,
CreateCredentialParams,
CreateCredentialResult,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
type SharedFido2ScriptInjectionDetails = {
runAt: browser.contentScripts.RegisteredContentScriptOptions["runAt"];
};
type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails & {
matches: string[];
excludeMatches: string[];
allFrames: true;
};
type Fido2ExtensionMessage = {
[key: string]: any;
command: string;
hostname?: string;
origin?: string;
requestId?: string;
abortedRequestId?: string;
data?: AssertCredentialParams | CreateCredentialParams;
};
type Fido2ExtensionMessageEventParams = {
message: Fido2ExtensionMessage;
sender: chrome.runtime.MessageSender;
};
type Fido2BackgroundExtensionMessageHandlers = {
[key: string]: CallableFunction;
fido2AbortRequest: ({ message }: Fido2ExtensionMessageEventParams) => void;
fido2RegisterCredentialRequest: ({
message,
sender,
}: Fido2ExtensionMessageEventParams) => Promise<CreateCredentialResult>;
fido2GetCredentialRequest: ({
message,
sender,
}: Fido2ExtensionMessageEventParams) => Promise<AssertCredentialResult>;
};
interface Fido2Background {
init(): void;
injectFido2ContentScriptsInAllTabs(): Promise<void>;
}
export {
SharedFido2ScriptInjectionDetails,
SharedFido2ScriptRegistrationOptions,
Fido2ExtensionMessage,
Fido2BackgroundExtensionMessageHandlers,
Fido2Background,
};

View File

@ -0,0 +1,414 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
AssertCredentialParams,
CreateCredentialParams,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { Fido2ClientService } from "@bitwarden/common/vault/services/fido2/fido2-client.service";
import { createPortSpyMock } from "../../../autofill/spec/autofill-mocks";
import {
flushPromises,
sendExtensionRuntimeMessage,
triggerPortOnDisconnectEvent,
triggerRuntimeOnConnectEvent,
} from "../../../autofill/spec/testing-utils";
import { BrowserApi } from "../../../platform/browser/browser-api";
import { BrowserScriptInjectorService } from "../../../platform/services/browser-script-injector.service";
import { AbortManager } from "../../background/abort-manager";
import { Fido2ContentScript, Fido2ContentScriptId } from "../enums/fido2-content-script.enum";
import { Fido2PortName } from "../enums/fido2-port-name.enum";
import { Fido2ExtensionMessage } from "./abstractions/fido2.background";
import { Fido2Background } from "./fido2.background";
const sharedExecuteScriptOptions = { runAt: "document_start" };
const sharedScriptInjectionDetails = { frame: "all_frames", ...sharedExecuteScriptOptions };
const contentScriptDetails = {
file: Fido2ContentScript.ContentScript,
...sharedScriptInjectionDetails,
};
const sharedRegistrationOptions = {
matches: ["https://*/*"],
excludeMatches: ["https://*/*.xml*"],
allFrames: true,
...sharedExecuteScriptOptions,
};
describe("Fido2Background", () => {
const tabsQuerySpy: jest.SpyInstance = jest.spyOn(BrowserApi, "tabsQuery");
const isManifestVersionSpy: jest.SpyInstance = jest.spyOn(BrowserApi, "isManifestVersion");
const focusTabSpy: jest.SpyInstance = jest.spyOn(BrowserApi, "focusTab").mockResolvedValue();
const focusWindowSpy: jest.SpyInstance = jest
.spyOn(BrowserApi, "focusWindow")
.mockResolvedValue();
let abortManagerMock!: MockProxy<AbortManager>;
let abortController!: MockProxy<AbortController>;
let registeredContentScripsMock!: MockProxy<browser.contentScripts.RegisteredContentScript>;
let tabMock!: MockProxy<chrome.tabs.Tab>;
let senderMock!: MockProxy<chrome.runtime.MessageSender>;
let logService!: MockProxy<LogService>;
let fido2ClientService!: MockProxy<Fido2ClientService>;
let vaultSettingsService!: MockProxy<VaultSettingsService>;
let scriptInjectorServiceMock!: MockProxy<BrowserScriptInjectorService>;
let enablePasskeysMock$!: BehaviorSubject<boolean>;
let fido2Background!: Fido2Background;
beforeEach(() => {
tabMock = mock<chrome.tabs.Tab>({
id: 123,
url: "https://example.com",
windowId: 456,
});
senderMock = mock<chrome.runtime.MessageSender>({ id: "1", tab: tabMock });
logService = mock<LogService>();
fido2ClientService = mock<Fido2ClientService>();
vaultSettingsService = mock<VaultSettingsService>();
abortManagerMock = mock<AbortManager>();
abortController = mock<AbortController>();
registeredContentScripsMock = mock<browser.contentScripts.RegisteredContentScript>();
scriptInjectorServiceMock = mock<BrowserScriptInjectorService>();
enablePasskeysMock$ = new BehaviorSubject(true);
vaultSettingsService.enablePasskeys$ = enablePasskeysMock$;
fido2ClientService.isFido2FeatureEnabled.mockResolvedValue(true);
fido2Background = new Fido2Background(
logService,
fido2ClientService,
vaultSettingsService,
scriptInjectorServiceMock,
);
fido2Background["abortManager"] = abortManagerMock;
abortManagerMock.runWithAbortController.mockImplementation((_requestId, runner) =>
runner(abortController),
);
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 2);
});
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
describe("injectFido2ContentScriptsInAllTabs", () => {
it("does not inject any FIDO2 content scripts when no tabs have a secure url protocol", async () => {
const insecureTab = mock<chrome.tabs.Tab>({ id: 789, url: "http://example.com" });
tabsQuerySpy.mockResolvedValueOnce([insecureTab]);
await fido2Background.injectFido2ContentScriptsInAllTabs();
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled();
});
it("only injects the FIDO2 content script into tabs that contain a secure url protocol", async () => {
const secondTabMock = mock<chrome.tabs.Tab>({ id: 456, url: "https://example.com" });
const insecureTab = mock<chrome.tabs.Tab>({ id: 789, url: "http://example.com" });
const noUrlTab = mock<chrome.tabs.Tab>({ id: 101, url: undefined });
tabsQuerySpy.mockResolvedValueOnce([tabMock, secondTabMock, insecureTab, noUrlTab]);
await fido2Background.injectFido2ContentScriptsInAllTabs();
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
tabId: tabMock.id,
injectDetails: contentScriptDetails,
});
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
tabId: secondTabMock.id,
injectDetails: contentScriptDetails,
});
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalledWith({
tabId: insecureTab.id,
injectDetails: contentScriptDetails,
});
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalledWith({
tabId: noUrlTab.id,
injectDetails: contentScriptDetails,
});
});
it("injects the `page-script.js` content script into the provided tab", async () => {
tabsQuerySpy.mockResolvedValueOnce([tabMock]);
await fido2Background.injectFido2ContentScriptsInAllTabs();
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
tabId: tabMock.id,
injectDetails: sharedScriptInjectionDetails,
mv2Details: { file: Fido2ContentScript.PageScriptAppend },
mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" },
});
});
});
describe("handleEnablePasskeysUpdate", () => {
let portMock!: MockProxy<chrome.runtime.Port>;
beforeEach(() => {
fido2Background.init();
jest.spyOn(BrowserApi, "registerContentScriptsMv2");
jest.spyOn(BrowserApi, "registerContentScriptsMv3");
jest.spyOn(BrowserApi, "unregisterContentScriptsMv3");
portMock = createPortSpyMock(Fido2PortName.InjectedScript);
triggerRuntimeOnConnectEvent(portMock);
triggerRuntimeOnConnectEvent(createPortSpyMock("some-other-port"));
tabsQuerySpy.mockResolvedValue([tabMock]);
});
it("does not destroy and re-inject the content scripts when triggering `handleEnablePasskeysUpdate` with an undefined currentEnablePasskeysSetting property", async () => {
await flushPromises();
expect(portMock.disconnect).not.toHaveBeenCalled();
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled();
});
it("destroys the content scripts but skips re-injecting them when the enablePasskeys setting is set to `false`", async () => {
enablePasskeysMock$.next(false);
await flushPromises();
expect(portMock.disconnect).toHaveBeenCalled();
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled();
});
it("destroys and re-injects the content scripts when the enablePasskeys setting is set to `true`", async () => {
enablePasskeysMock$.next(true);
await flushPromises();
expect(portMock.disconnect).toHaveBeenCalled();
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
tabId: tabMock.id,
injectDetails: sharedScriptInjectionDetails,
mv2Details: { file: Fido2ContentScript.PageScriptAppend },
mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" },
});
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
tabId: tabMock.id,
injectDetails: contentScriptDetails,
});
});
describe("given manifest v2", () => {
it("registers the page-script-append-mv2.js and content-script.js content scripts when the enablePasskeys setting is set to `true`", async () => {
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 2);
enablePasskeysMock$.next(true);
await flushPromises();
expect(BrowserApi.registerContentScriptsMv2).toHaveBeenCalledWith({
js: [
{ file: Fido2ContentScript.PageScriptAppend },
{ file: Fido2ContentScript.ContentScript },
],
...sharedRegistrationOptions,
});
});
it("unregisters any existing registered content scripts when the enablePasskeys setting is set to `false`", async () => {
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 2);
fido2Background["registeredContentScripts"] = registeredContentScripsMock;
enablePasskeysMock$.next(false);
await flushPromises();
expect(registeredContentScripsMock.unregister).toHaveBeenCalled();
expect(BrowserApi.registerContentScriptsMv2).not.toHaveBeenCalledTimes(2);
});
});
describe("given manifest v3", () => {
it("registers the page-script.js and content-script.js content scripts when the enablePasskeys setting is set to `true`", async () => {
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 3);
enablePasskeysMock$.next(true);
await flushPromises();
expect(BrowserApi.registerContentScriptsMv3).toHaveBeenCalledWith([
{
id: Fido2ContentScriptId.PageScript,
js: [Fido2ContentScript.PageScript],
world: "MAIN",
...sharedRegistrationOptions,
},
{
id: Fido2ContentScriptId.ContentScript,
js: [Fido2ContentScript.ContentScript],
...sharedRegistrationOptions,
},
]);
expect(BrowserApi.unregisterContentScriptsMv3).not.toHaveBeenCalled();
});
it("unregisters the page-script.js and content-script.js content scripts when the enablePasskeys setting is set to `false`", async () => {
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 3);
enablePasskeysMock$.next(false);
await flushPromises();
expect(BrowserApi.unregisterContentScriptsMv3).toHaveBeenCalledWith({
ids: [Fido2ContentScriptId.PageScript, Fido2ContentScriptId.ContentScript],
});
expect(BrowserApi.registerContentScriptsMv3).not.toHaveBeenCalledTimes(2);
});
});
});
describe("extension message handlers", () => {
beforeEach(() => {
fido2Background.init();
});
it("ignores messages that do not have a handler associated with a command within the message", () => {
const message = mock<Fido2ExtensionMessage>({ command: "nonexistentCommand" });
sendExtensionRuntimeMessage(message);
expect(abortManagerMock.abort).not.toHaveBeenCalled();
});
it("sends a response for rejected promises returned by a handler", async () => {
const message = mock<Fido2ExtensionMessage>({ command: "fido2RegisterCredentialRequest" });
const sender = mock<chrome.runtime.MessageSender>();
const sendResponse = jest.fn();
fido2ClientService.createCredential.mockRejectedValue(new Error("error"));
sendExtensionRuntimeMessage(message, sender, sendResponse);
await flushPromises();
expect(sendResponse).toHaveBeenCalledWith({ error: { message: "error" } });
});
describe("fido2AbortRequest message", () => {
it("aborts the request associated with the passed abortedRequestId", async () => {
const message = mock<Fido2ExtensionMessage>({
command: "fido2AbortRequest",
abortedRequestId: "123",
});
sendExtensionRuntimeMessage(message);
await flushPromises();
expect(abortManagerMock.abort).toHaveBeenCalledWith(message.abortedRequestId);
});
});
describe("fido2RegisterCredentialRequest message", () => {
it("creates a credential within the Fido2ClientService", async () => {
const message = mock<Fido2ExtensionMessage>({
command: "fido2RegisterCredentialRequest",
requestId: "123",
data: mock<CreateCredentialParams>(),
});
sendExtensionRuntimeMessage(message, senderMock);
await flushPromises();
expect(fido2ClientService.createCredential).toHaveBeenCalledWith(
message.data,
tabMock,
abortController,
);
expect(focusTabSpy).toHaveBeenCalledWith(tabMock.id);
expect(focusWindowSpy).toHaveBeenCalledWith(tabMock.windowId);
});
});
describe("fido2GetCredentialRequest", () => {
it("asserts a credential within the Fido2ClientService", async () => {
const message = mock<Fido2ExtensionMessage>({
command: "fido2GetCredentialRequest",
requestId: "123",
data: mock<AssertCredentialParams>(),
});
sendExtensionRuntimeMessage(message, senderMock);
await flushPromises();
expect(fido2ClientService.assertCredential).toHaveBeenCalledWith(
message.data,
tabMock,
abortController,
);
expect(focusTabSpy).toHaveBeenCalledWith(tabMock.id);
expect(focusWindowSpy).toHaveBeenCalledWith(tabMock.windowId);
});
});
});
describe("handle ports onConnect", () => {
let portMock!: MockProxy<chrome.runtime.Port>;
beforeEach(() => {
fido2Background.init();
portMock = createPortSpyMock(Fido2PortName.InjectedScript);
fido2ClientService.isFido2FeatureEnabled.mockResolvedValue(true);
});
it("ignores port connections that do not have the correct port name", async () => {
const port = createPortSpyMock("nonexistentPort");
triggerRuntimeOnConnectEvent(port);
await flushPromises();
expect(port.onDisconnect.addListener).not.toHaveBeenCalled();
});
it("ignores port connections that do not have a sender url", async () => {
portMock.sender = undefined;
triggerRuntimeOnConnectEvent(portMock);
await flushPromises();
expect(portMock.onDisconnect.addListener).not.toHaveBeenCalled();
});
it("disconnects the port connection when the Fido2 feature is not enabled", async () => {
fido2ClientService.isFido2FeatureEnabled.mockResolvedValue(false);
triggerRuntimeOnConnectEvent(portMock);
await flushPromises();
expect(portMock.disconnect).toHaveBeenCalled();
});
it("disconnects the port connection when the url is malformed", async () => {
portMock.sender.url = "malformed-url";
triggerRuntimeOnConnectEvent(portMock);
await flushPromises();
expect(portMock.disconnect).toHaveBeenCalled();
expect(logService.error).toHaveBeenCalled();
});
it("adds the port to the fido2ContentScriptPortsSet when the Fido2 feature is enabled", async () => {
triggerRuntimeOnConnectEvent(portMock);
await flushPromises();
expect(portMock.onDisconnect.addListener).toHaveBeenCalled();
});
});
describe("handleInjectScriptPortOnDisconnect", () => {
let portMock!: MockProxy<chrome.runtime.Port>;
beforeEach(() => {
fido2Background.init();
portMock = createPortSpyMock(Fido2PortName.InjectedScript);
triggerRuntimeOnConnectEvent(portMock);
fido2Background["fido2ContentScriptPortsSet"].add(portMock);
});
it("does not destroy or inject the content script when the port has already disconnected before the enablePasskeys setting is set to `false`", async () => {
triggerPortOnDisconnectEvent(portMock);
enablePasskeysMock$.next(false);
await flushPromises();
expect(portMock.disconnect).not.toHaveBeenCalled();
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,356 @@
import { firstValueFrom, startWith } from "rxjs";
import { pairwise } from "rxjs/operators";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
AssertCredentialParams,
AssertCredentialResult,
CreateCredentialParams,
CreateCredentialResult,
Fido2ClientService,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { BrowserApi } from "../../../platform/browser/browser-api";
import { ScriptInjectorService } from "../../../platform/services/abstractions/script-injector.service";
import { AbortManager } from "../../background/abort-manager";
import { Fido2ContentScript, Fido2ContentScriptId } from "../enums/fido2-content-script.enum";
import { Fido2PortName } from "../enums/fido2-port-name.enum";
import {
Fido2Background as Fido2BackgroundInterface,
Fido2BackgroundExtensionMessageHandlers,
Fido2ExtensionMessage,
SharedFido2ScriptInjectionDetails,
SharedFido2ScriptRegistrationOptions,
} from "./abstractions/fido2.background";
export class Fido2Background implements Fido2BackgroundInterface {
private abortManager = new AbortManager();
private fido2ContentScriptPortsSet = new Set<chrome.runtime.Port>();
private registeredContentScripts: browser.contentScripts.RegisteredContentScript;
private readonly sharedInjectionDetails: SharedFido2ScriptInjectionDetails = {
runAt: "document_start",
};
private readonly sharedRegistrationOptions: SharedFido2ScriptRegistrationOptions = {
matches: ["https://*/*"],
excludeMatches: ["https://*/*.xml*"],
allFrames: true,
...this.sharedInjectionDetails,
};
private readonly extensionMessageHandlers: Fido2BackgroundExtensionMessageHandlers = {
fido2AbortRequest: ({ message }) => this.abortRequest(message),
fido2RegisterCredentialRequest: ({ message, sender }) =>
this.registerCredentialRequest(message, sender),
fido2GetCredentialRequest: ({ message, sender }) => this.getCredentialRequest(message, sender),
};
constructor(
private logService: LogService,
private fido2ClientService: Fido2ClientService,
private vaultSettingsService: VaultSettingsService,
private scriptInjectorService: ScriptInjectorService,
) {}
/**
* Initializes the FIDO2 background service. Sets up the extension message
* and port listeners. Subscribes to the enablePasskeys$ observable to
* handle passkey enable/disable events.
*/
init() {
BrowserApi.messageListener("fido2.background", this.handleExtensionMessage);
BrowserApi.addListener(chrome.runtime.onConnect, this.handleInjectedScriptPortConnection);
this.vaultSettingsService.enablePasskeys$
.pipe(startWith(undefined), pairwise())
.subscribe(([previous, current]) => this.handleEnablePasskeysUpdate(previous, current));
}
/**
* Injects the FIDO2 content and page script into all existing browser tabs.
*/
async injectFido2ContentScriptsInAllTabs() {
const tabs = await BrowserApi.tabsQuery({});
for (let index = 0; index < tabs.length; index++) {
const tab = tabs[index];
if (!tab.url?.startsWith("https")) {
continue;
}
void this.injectFido2ContentScripts(tab);
}
}
/**
* Handles reacting to the enablePasskeys setting being updated. If the setting
* is enabled, the FIDO2 content scripts are injected into all tabs. If the setting
* is disabled, the FIDO2 content scripts will be from all tabs. This logic will
* not trigger until after the first setting update.
*
* @param previousEnablePasskeysSetting - The previous value of the enablePasskeys setting.
* @param enablePasskeys - The new value of the enablePasskeys setting.
*/
private async handleEnablePasskeysUpdate(
previousEnablePasskeysSetting: boolean,
enablePasskeys: boolean,
) {
await this.updateContentScriptRegistration();
if (previousEnablePasskeysSetting === undefined) {
return;
}
this.destroyLoadedFido2ContentScripts();
if (enablePasskeys) {
void this.injectFido2ContentScriptsInAllTabs();
}
}
/**
* Updates the registration status of static FIDO2 content
* scripts based on the enablePasskeys setting.
*/
private async updateContentScriptRegistration() {
if (BrowserApi.isManifestVersion(2)) {
await this.updateMv2ContentScriptsRegistration();
return;
}
await this.updateMv3ContentScriptsRegistration();
}
/**
* Updates the registration status of static FIDO2 content
* scripts based on the enablePasskeys setting for manifest v2.
*/
private async updateMv2ContentScriptsRegistration() {
if (!(await this.isPasskeySettingEnabled())) {
await this.registeredContentScripts?.unregister();
return;
}
this.registeredContentScripts = await BrowserApi.registerContentScriptsMv2({
js: [
{ file: Fido2ContentScript.PageScriptAppend },
{ file: Fido2ContentScript.ContentScript },
],
...this.sharedRegistrationOptions,
});
}
/**
* Updates the registration status of static FIDO2 content
* scripts based on the enablePasskeys setting for manifest v3.
*/
private async updateMv3ContentScriptsRegistration() {
if (await this.isPasskeySettingEnabled()) {
void BrowserApi.registerContentScriptsMv3([
{
id: Fido2ContentScriptId.PageScript,
js: [Fido2ContentScript.PageScript],
world: "MAIN",
...this.sharedRegistrationOptions,
},
{
id: Fido2ContentScriptId.ContentScript,
js: [Fido2ContentScript.ContentScript],
...this.sharedRegistrationOptions,
},
]);
return;
}
void BrowserApi.unregisterContentScriptsMv3({
ids: [Fido2ContentScriptId.PageScript, Fido2ContentScriptId.ContentScript],
});
}
/**
* Injects the FIDO2 content and page script into the current tab.
*
* @param tab - The current tab to inject the scripts into.
*/
private async injectFido2ContentScripts(tab: chrome.tabs.Tab): Promise<void> {
void this.scriptInjectorService.inject({
tabId: tab.id,
injectDetails: { frame: "all_frames", ...this.sharedInjectionDetails },
mv2Details: { file: Fido2ContentScript.PageScriptAppend },
mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" },
});
void this.scriptInjectorService.inject({
tabId: tab.id,
injectDetails: {
file: Fido2ContentScript.ContentScript,
frame: "all_frames",
...this.sharedInjectionDetails,
},
});
}
/**
* Iterates over the set of injected FIDO2 content script ports
* and disconnects them, destroying the content scripts.
*/
private destroyLoadedFido2ContentScripts() {
this.fido2ContentScriptPortsSet.forEach((port) => {
port.disconnect();
this.fido2ContentScriptPortsSet.delete(port);
});
}
/**
* Aborts the FIDO2 request with the provided requestId.
*
* @param message - The FIDO2 extension message containing the requestId to abort.
*/
private abortRequest(message: Fido2ExtensionMessage) {
this.abortManager.abort(message.abortedRequestId);
}
/**
* Registers a new FIDO2 credential with the provided request data.
*
* @param message - The FIDO2 extension message containing the request data.
* @param sender - The sender of the message.
*/
private async registerCredentialRequest(
message: Fido2ExtensionMessage,
sender: chrome.runtime.MessageSender,
): Promise<CreateCredentialResult> {
return await this.handleCredentialRequest<CreateCredentialResult>(
message,
sender.tab,
this.fido2ClientService.createCredential.bind(this.fido2ClientService),
);
}
/**
* Gets a FIDO2 credential with the provided request data.
*
* @param message - The FIDO2 extension message containing the request data.
* @param sender - The sender of the message.
*/
private async getCredentialRequest(
message: Fido2ExtensionMessage,
sender: chrome.runtime.MessageSender,
): Promise<AssertCredentialResult> {
return await this.handleCredentialRequest<AssertCredentialResult>(
message,
sender.tab,
this.fido2ClientService.assertCredential.bind(this.fido2ClientService),
);
}
/**
* Handles Fido2 credential requests by calling the provided callback with the
* request data, tab, and abort controller. The callback is expected to return
* a promise that resolves with the result of the credential request.
*
* @param requestId - The request ID associated with the request.
* @param data - The request data to handle.
* @param tab - The tab associated with the request.
* @param callback - The callback to call with the request data, tab, and abort controller.
*/
private handleCredentialRequest = async <T>(
{ requestId, data }: Fido2ExtensionMessage,
tab: chrome.tabs.Tab,
callback: (
data: AssertCredentialParams | CreateCredentialParams,
tab: chrome.tabs.Tab,
abortController: AbortController,
) => Promise<T>,
) => {
return await this.abortManager.runWithAbortController(requestId, async (abortController) => {
try {
return await callback(data, tab, abortController);
} finally {
await BrowserApi.focusTab(tab.id);
await BrowserApi.focusWindow(tab.windowId);
}
});
};
/**
* Checks if the enablePasskeys setting is enabled.
*/
private async isPasskeySettingEnabled() {
return await firstValueFrom(this.vaultSettingsService.enablePasskeys$);
}
/**
* Handles the FIDO2 extension message by calling the
* appropriate handler based on the message command.
*
* @param message - The FIDO2 extension message to handle.
* @param sender - The sender of the message.
* @param sendResponse - The function to call with the response.
*/
private handleExtensionMessage = (
message: Fido2ExtensionMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void,
) => {
const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command];
if (!handler) {
return;
}
const messageResponse = handler({ message, sender });
if (!messageResponse) {
return;
}
Promise.resolve(messageResponse)
.then(
(response) => sendResponse(response),
(error) => sendResponse({ error: { ...error, message: error.message } }),
)
.catch(this.logService.error);
return true;
};
/**
* Handles the connection of a FIDO2 content script port by checking if the
* FIDO2 feature is enabled for the sender's hostname and origin. If the feature
* is not enabled, the port is disconnected.
*
* @param port - The port which is connecting
*/
private handleInjectedScriptPortConnection = async (port: chrome.runtime.Port) => {
if (port.name !== Fido2PortName.InjectedScript || !port.sender?.url) {
return;
}
try {
const { hostname, origin } = new URL(port.sender.url);
if (!(await this.fido2ClientService.isFido2FeatureEnabled(hostname, origin))) {
port.disconnect();
return;
}
this.fido2ContentScriptPortsSet.add(port);
port.onDisconnect.addListener(this.handleInjectScriptPortOnDisconnect);
} catch (error) {
this.logService.error(error);
port.disconnect();
}
};
/**
* Handles the disconnection of a FIDO2 content script port
* by removing it from the set of connected ports.
*
* @param port - The port which is disconnecting
*/
private handleInjectScriptPortOnDisconnect = (port: chrome.runtime.Port) => {
if (port.name !== Fido2PortName.InjectedScript) {
return;
}
this.fido2ContentScriptPortsSet.delete(port);
};
}

View File

@ -0,0 +1,164 @@
import { mock, MockProxy } from "jest-mock-extended";
import { CreateCredentialResult } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { createPortSpyMock } from "../../../autofill/spec/autofill-mocks";
import { triggerPortOnDisconnectEvent } from "../../../autofill/spec/testing-utils";
import { Fido2PortName } from "../enums/fido2-port-name.enum";
import { InsecureCreateCredentialParams, MessageType } from "./messaging/message";
import { MessageWithMetadata, Messenger } from "./messaging/messenger";
jest.mock("../../../autofill/utils", () => ({
sendExtensionMessage: jest.fn((command, options) => {
return chrome.runtime.sendMessage(Object.assign({ command }, options));
}),
}));
describe("Fido2 Content Script", () => {
let messenger: Messenger;
const messengerForDOMCommunicationSpy = jest
.spyOn(Messenger, "forDOMCommunication")
.mockImplementation((window) => {
const windowOrigin = window.location.origin;
messenger = new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => window.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener),
});
messenger.destroy = jest.fn();
return messenger;
});
const portSpy: MockProxy<chrome.runtime.Port> = createPortSpyMock(Fido2PortName.InjectedScript);
chrome.runtime.connect = jest.fn(() => portSpy);
afterEach(() => {
Object.defineProperty(document, "contentType", {
value: "text/html",
writable: true,
});
jest.clearAllMocks();
jest.resetModules();
});
it("destroys the messenger when the port is disconnected", () => {
require("./content-script");
triggerPortOnDisconnectEvent(portSpy);
expect(messenger.destroy).toHaveBeenCalled();
});
it("handles a FIDO2 credential creation request message from the window message listener, formats the message and sends the formatted message to the extension background", async () => {
const message = mock<MessageWithMetadata>({
type: MessageType.CredentialCreationRequest,
data: mock<InsecureCreateCredentialParams>(),
});
const mockResult = { credentialId: "mock" } as CreateCredentialResult;
jest.spyOn(chrome.runtime, "sendMessage").mockResolvedValue(mockResult);
require("./content-script");
const response = await messenger.handler!(message, new AbortController());
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "fido2RegisterCredentialRequest",
data: expect.objectContaining({
origin: globalThis.location.origin,
sameOriginWithAncestors: true,
}),
requestId: expect.any(String),
});
expect(response).toEqual({
type: MessageType.CredentialCreationResponse,
result: mockResult,
});
});
it("handles a FIDO2 credential get request message from the window message listener, formats the message and sends the formatted message to the extension background", async () => {
const message = mock<MessageWithMetadata>({
type: MessageType.CredentialGetRequest,
data: mock<InsecureCreateCredentialParams>(),
});
require("./content-script");
await messenger.handler!(message, new AbortController());
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "fido2GetCredentialRequest",
data: expect.objectContaining({
origin: globalThis.location.origin,
sameOriginWithAncestors: true,
}),
requestId: expect.any(String),
});
});
it("removes the abort handler when the FIDO2 request is complete", async () => {
const message = mock<MessageWithMetadata>({
type: MessageType.CredentialCreationRequest,
data: mock<InsecureCreateCredentialParams>(),
});
const abortController = new AbortController();
const abortSpy = jest.spyOn(abortController.signal, "removeEventListener");
require("./content-script");
await messenger.handler!(message, abortController);
expect(abortSpy).toHaveBeenCalled();
});
it("sends an extension message to abort the FIDO2 request when the abort controller is signaled", async () => {
const message = mock<MessageWithMetadata>({
type: MessageType.CredentialCreationRequest,
data: mock<InsecureCreateCredentialParams>(),
});
const abortController = new AbortController();
const abortSpy = jest.spyOn(abortController.signal, "addEventListener");
jest
.spyOn(chrome.runtime, "sendMessage")
.mockImplementationOnce(async (extensionId: string, message: unknown, options: any) => {
abortController.abort();
});
require("./content-script");
await messenger.handler!(message, abortController);
expect(abortSpy).toHaveBeenCalled();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "fido2AbortRequest",
abortedRequestId: expect.any(String),
});
});
it("rejects credential requests and returns an error result", async () => {
const errorMessage = "Test error";
const message = mock<MessageWithMetadata>({
type: MessageType.CredentialCreationRequest,
data: mock<InsecureCreateCredentialParams>(),
});
const abortController = new AbortController();
jest.spyOn(chrome.runtime, "sendMessage").mockResolvedValue({ error: errorMessage });
require("./content-script");
const result = messenger.handler!(message, abortController);
await expect(result).rejects.toEqual(errorMessage);
});
it("skips initializing the content script if the document content type is not 'text/html'", () => {
Object.defineProperty(document, "contentType", {
value: "application/json",
writable: true,
});
require("./content-script");
expect(messengerForDOMCommunicationSpy).not.toHaveBeenCalled();
});
});

View File

@ -3,142 +3,134 @@ import {
CreateCredentialParams,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { Message, MessageType } from "./messaging/message";
import { Messenger } from "./messaging/messenger";
import { sendExtensionMessage } from "../../../autofill/utils";
import { Fido2PortName } from "../enums/fido2-port-name.enum";
function isFido2FeatureEnabled(): Promise<boolean> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(
{
command: "checkFido2FeatureEnabled",
hostname: window.location.hostname,
origin: window.location.origin,
},
(response: { result?: boolean }) => resolve(response.result),
);
});
}
function isSameOriginWithAncestors() {
try {
return window.self === window.top;
} catch {
return false;
}
}
const messenger = Messenger.forDOMCommunication(window);
function injectPageScript() {
// Locate an existing page-script on the page
const existingPageScript = document.getElementById("bw-fido2-page-script");
// Inject the page-script if it doesn't exist
if (!existingPageScript) {
const s = document.createElement("script");
s.src = chrome.runtime.getURL("content/fido2/page-script.js");
s.id = "bw-fido2-page-script";
(document.head || document.documentElement).appendChild(s);
import {
InsecureAssertCredentialParams,
InsecureCreateCredentialParams,
Message,
MessageType,
} from "./messaging/message";
import { MessageWithMetadata, Messenger } from "./messaging/messenger";
(function (globalContext) {
if (globalContext.document.contentType !== "text/html") {
return;
}
// If the page-script already exists, send a reconnect message to the page-script
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
messenger.sendReconnectCommand();
}
// Initialization logic, set up the messenger and connect a port to the background script.
const messenger = Messenger.forDOMCommunication(globalContext.window);
messenger.handler = handleFido2Message;
const port = chrome.runtime.connect({ name: Fido2PortName.InjectedScript });
port.onDisconnect.addListener(handlePortOnDisconnect);
function initializeFido2ContentScript() {
injectPageScript();
messenger.handler = async (message, abortController) => {
/**
* Handles FIDO2 credential requests and returns the result.
*
* @param message - The message to handle.
* @param abortController - The abort controller used to handle exit conditions from the FIDO2 request.
*/
async function handleFido2Message(
message: MessageWithMetadata,
abortController: AbortController,
) {
const requestId = Date.now().toString();
const abortHandler = () =>
chrome.runtime.sendMessage({
command: "fido2AbortRequest",
abortedRequestId: requestId,
});
sendExtensionMessage("fido2AbortRequest", { abortedRequestId: requestId });
abortController.signal.addEventListener("abort", abortHandler);
if (message.type === MessageType.CredentialCreationRequest) {
return new Promise<Message | undefined>((resolve, reject) => {
const data: CreateCredentialParams = {
...message.data,
origin: window.location.origin,
sameOriginWithAncestors: isSameOriginWithAncestors(),
};
chrome.runtime.sendMessage(
{
command: "fido2RegisterCredentialRequest",
data,
requestId: requestId,
},
(response) => {
if (response && response.error !== undefined) {
return reject(response.error);
}
resolve({
type: MessageType.CredentialCreationResponse,
result: response.result,
});
},
try {
if (message.type === MessageType.CredentialCreationRequest) {
return handleCredentialCreationRequestMessage(
requestId,
message.data as InsecureCreateCredentialParams,
);
});
}
}
if (message.type === MessageType.CredentialGetRequest) {
return new Promise<Message | undefined>((resolve, reject) => {
const data: AssertCredentialParams = {
...message.data,
origin: window.location.origin,
sameOriginWithAncestors: isSameOriginWithAncestors(),
};
chrome.runtime.sendMessage(
{
command: "fido2GetCredentialRequest",
data,
requestId: requestId,
},
(response) => {
if (response && response.error !== undefined) {
return reject(response.error);
}
resolve({
type: MessageType.CredentialGetResponse,
result: response.result,
});
},
if (message.type === MessageType.CredentialGetRequest) {
return handleCredentialGetRequestMessage(
requestId,
message.data as InsecureAssertCredentialParams,
);
}).finally(() =>
abortController.signal.removeEventListener("abort", abortHandler),
) as Promise<Message>;
}
} finally {
abortController.signal.removeEventListener("abort", abortHandler);
}
return undefined;
};
}
async function run() {
if (!(await isFido2FeatureEnabled())) {
return;
}
initializeFido2ContentScript();
/**
* Handles the credential creation request message and returns the result.
*
* @param requestId - The request ID of the message.
* @param data - Data associated with the credential request.
*/
async function handleCredentialCreationRequestMessage(
requestId: string,
data: InsecureCreateCredentialParams,
): Promise<Message | undefined> {
return respondToCredentialRequest(
"fido2RegisterCredentialRequest",
MessageType.CredentialCreationResponse,
requestId,
data,
);
}
const port = chrome.runtime.connect({ name: "fido2ContentScriptReady" });
port.onDisconnect.addListener(() => {
// Cleanup the messenger and remove the event listener
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
messenger.destroy();
});
}
/**
* Handles the credential get request message and returns the result.
*
* @param requestId - The request ID of the message.
* @param data - Data associated with the credential request.
*/
async function handleCredentialGetRequestMessage(
requestId: string,
data: InsecureAssertCredentialParams,
): Promise<Message | undefined> {
return respondToCredentialRequest(
"fido2GetCredentialRequest",
MessageType.CredentialGetResponse,
requestId,
data,
);
}
// Only run the script if the document is an HTML document
if (document.contentType === "text/html") {
void run();
}
/**
* Sends a message to the extension to handle the
* credential request and returns the result.
*
* @param command - The command to send to the extension.
* @param type - The type of message, either CredentialCreationResponse or CredentialGetResponse.
* @param requestId - The request ID of the message.
* @param messageData - Data associated with the credential request.
*/
async function respondToCredentialRequest(
command: string,
type: MessageType.CredentialCreationResponse | MessageType.CredentialGetResponse,
requestId: string,
messageData: InsecureCreateCredentialParams | InsecureAssertCredentialParams,
): Promise<Message | undefined> {
const data: CreateCredentialParams | AssertCredentialParams = {
...messageData,
origin: globalContext.location.origin,
sameOriginWithAncestors: globalContext.self === globalContext.top,
};
const result = await sendExtensionMessage(command, { data, requestId });
if (result && result.error !== undefined) {
return Promise.reject(result.error);
}
return Promise.resolve({ type, result });
}
/**
* Handles the disconnect event of the port. Calls
* to the messenger to destroy and tear down the
* implemented page-script.js logic.
*/
function handlePortOnDisconnect() {
void messenger.destroy();
}
})(globalThis);

View File

@ -68,7 +68,7 @@ describe("Messenger", () => {
const abortController = new AbortController();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
messengerA.request(createRequest(), abortController);
messengerA.request(createRequest(), abortController.signal);
abortController.abort();
const received = handlerB.receive();

View File

@ -47,7 +47,7 @@ export class Messenger {
}
/**
* The handler that will be called when a message is recieved. The handler should return
* The handler that will be called when a message is received. The handler should return
* a promise that resolves to the response message. If the handler throws an error, the
* error will be sent back to the sender.
*/
@ -65,10 +65,10 @@ export class Messenger {
* AbortController signals will be forwarded to the content script.
*
* @param request data to send to the content script
* @param abortController the abort controller that might be used to abort the request
* @param abortSignal the abort controller that might be used to abort the request
* @returns the response from the content script
*/
async request(request: Message, abortController?: AbortController): Promise<Message> {
async request(request: Message, abortSignal?: AbortSignal): Promise<Message> {
const requestChannel = new MessageChannel();
const { port1: localPort, port2: remotePort } = requestChannel;
@ -82,7 +82,7 @@ export class Messenger {
metadata: { SENDER },
type: MessageType.AbortRequest,
});
abortController?.signal.addEventListener("abort", abortListener);
abortSignal?.addEventListener("abort", abortListener);
this.broadcastChannel.postMessage(
{ ...request, SENDER, senderId: this.messengerId },
@ -90,7 +90,7 @@ export class Messenger {
);
const response = await promise;
abortController?.signal.removeEventListener("abort", abortListener);
abortSignal?.removeEventListener("abort", abortListener);
if (response.type === MessageType.ErrorResponse) {
const error = new Error();
@ -113,12 +113,7 @@ export class Messenger {
const message = event.data;
const port = event.ports?.[0];
if (
message?.SENDER !== SENDER ||
message.senderId == this.messengerId ||
message == null ||
port == null
) {
if (message?.SENDER !== SENDER || message.senderId == this.messengerId || port == null) {
return;
}
@ -167,10 +162,6 @@ export class Messenger {
}
}
async sendReconnectCommand() {
await this.request({ type: MessageType.ReconnectRequest });
}
private async sendDisconnectCommand() {
await this.request({ type: MessageType.DisconnectRequest });
}

View File

@ -0,0 +1,69 @@
import { Fido2ContentScript } from "../enums/fido2-content-script.enum";
describe("FIDO2 page-script for manifest v2", () => {
let createdScriptElement: HTMLScriptElement;
jest.spyOn(window.document, "createElement");
afterEach(() => {
Object.defineProperty(window.document, "contentType", { value: "text/html", writable: true });
jest.clearAllMocks();
jest.resetModules();
});
it("skips appending the `page-script.js` file if the document contentType is not `text/html`", () => {
Object.defineProperty(window.document, "contentType", { value: "text/plain", writable: true });
require("./page-script-append.mv2");
expect(window.document.createElement).not.toHaveBeenCalled();
});
it("appends the `page-script.js` file to the document head when the contentType is `text/html`", () => {
jest.spyOn(window.document.head, "insertBefore").mockImplementation((node) => {
createdScriptElement = node as HTMLScriptElement;
return node;
});
require("./page-script-append.mv2");
expect(window.document.createElement).toHaveBeenCalledWith("script");
expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript);
expect(window.document.head.insertBefore).toHaveBeenCalledWith(
expect.any(HTMLScriptElement),
window.document.head.firstChild,
);
expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`);
});
it("appends the `page-script.js` file to the document element if the head is not available", () => {
window.document.documentElement.removeChild(window.document.head);
jest.spyOn(window.document.documentElement, "insertBefore").mockImplementation((node) => {
createdScriptElement = node as HTMLScriptElement;
return node;
});
require("./page-script-append.mv2");
expect(window.document.createElement).toHaveBeenCalledWith("script");
expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript);
expect(window.document.documentElement.insertBefore).toHaveBeenCalledWith(
expect.any(HTMLScriptElement),
window.document.documentElement.firstChild,
);
expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`);
});
it("removes the appended `page-script.js` file after the script has triggered a load event", () => {
createdScriptElement = document.createElement("script");
jest.spyOn(window.document, "createElement").mockImplementation((element) => {
return createdScriptElement;
});
require("./page-script-append.mv2");
jest.spyOn(createdScriptElement, "remove");
createdScriptElement.dispatchEvent(new Event("load"));
expect(createdScriptElement.remove).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,19 @@
/**
* This script handles injection of the FIDO2 override page script into the document.
* This is required for manifest v2, but will be removed when we migrate fully to manifest v3.
*/
import { Fido2ContentScript } from "../enums/fido2-content-script.enum";
(function (globalContext) {
if (globalContext.document.contentType !== "text/html") {
return;
}
const script = globalContext.document.createElement("script");
script.src = chrome.runtime.getURL(Fido2ContentScript.PageScript);
script.addEventListener("load", () => script.remove());
const scriptInsertionPoint =
globalContext.document.head || globalContext.document.documentElement;
scriptInsertionPoint.insertBefore(script, scriptInsertionPoint.firstChild);
})(globalThis);

View File

@ -5,212 +5,229 @@ import { WebauthnUtils } from "../webauthn-utils";
import { MessageType } from "./messaging/message";
import { Messenger } from "./messaging/messenger";
const BrowserPublicKeyCredential = window.PublicKeyCredential;
const browserNativeWebauthnSupport = window.PublicKeyCredential != undefined;
let browserNativeWebauthnPlatformAuthenticatorSupport = false;
if (!browserNativeWebauthnSupport) {
// Polyfill webauthn support
try {
// credentials is read-only if supported, use type-casting to force assignment
(navigator as any).credentials = {
async create() {
throw new Error("Webauthn not supported in this browser.");
},
async get() {
throw new Error("Webauthn not supported in this browser.");
},
};
window.PublicKeyCredential = class PolyfillPublicKeyCredential {
static isUserVerifyingPlatformAuthenticatorAvailable() {
return Promise.resolve(true);
}
} as any;
window.AuthenticatorAttestationResponse =
class PolyfillAuthenticatorAttestationResponse {} as any;
} catch {
/* empty */
(function (globalContext) {
if (globalContext.document.contentType !== "text/html") {
return;
}
}
const BrowserPublicKeyCredential = globalContext.PublicKeyCredential;
const BrowserNavigatorCredentials = navigator.credentials;
const BrowserAuthenticatorAttestationResponse = globalContext.AuthenticatorAttestationResponse;
if (browserNativeWebauthnSupport) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
BrowserPublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then((available) => {
browserNativeWebauthnPlatformAuthenticatorSupport = available;
if (!available) {
// Polyfill platform authenticator support
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = () =>
Promise.resolve(true);
const browserNativeWebauthnSupport = globalContext.PublicKeyCredential != undefined;
let browserNativeWebauthnPlatformAuthenticatorSupport = false;
if (!browserNativeWebauthnSupport) {
// Polyfill webauthn support
try {
// credentials are read-only if supported, use type-casting to force assignment
(navigator as any).credentials = {
async create() {
throw new Error("Webauthn not supported in this browser.");
},
async get() {
throw new Error("Webauthn not supported in this browser.");
},
};
globalContext.PublicKeyCredential = class PolyfillPublicKeyCredential {
static isUserVerifyingPlatformAuthenticatorAvailable() {
return Promise.resolve(true);
}
} as any;
globalContext.AuthenticatorAttestationResponse =
class PolyfillAuthenticatorAttestationResponse {} as any;
} catch {
/* empty */
}
});
}
} else {
void BrowserPublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(
(available) => {
browserNativeWebauthnPlatformAuthenticatorSupport = available;
const browserCredentials = {
create: navigator.credentials.create.bind(
navigator.credentials,
) as typeof navigator.credentials.create,
get: navigator.credentials.get.bind(navigator.credentials) as typeof navigator.credentials.get,
};
const messenger = ((window as any).messenger = Messenger.forDOMCommunication(window));
navigator.credentials.create = createWebAuthnCredential;
navigator.credentials.get = getWebAuthnCredential;
/**
* Creates a new webauthn credential.
*
* @param options Options for creating new credentials.
* @param abortController Abort controller to abort the request if needed.
* @returns Promise that resolves to the new credential object.
*/
async function createWebAuthnCredential(
options?: CredentialCreationOptions,
abortController?: AbortController,
): Promise<Credential> {
if (!isWebauthnCall(options)) {
return await browserCredentials.create(options);
}
const fallbackSupported =
(options?.publicKey?.authenticatorSelection?.authenticatorAttachment === "platform" &&
browserNativeWebauthnPlatformAuthenticatorSupport) ||
(options?.publicKey?.authenticatorSelection?.authenticatorAttachment !== "platform" &&
browserNativeWebauthnSupport);
try {
const response = await messenger.request(
{
type: MessageType.CredentialCreationRequest,
data: WebauthnUtils.mapCredentialCreationOptions(options, fallbackSupported),
if (!available) {
// Polyfill platform authenticator support
globalContext.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = () =>
Promise.resolve(true);
}
},
abortController,
);
}
if (response.type !== MessageType.CredentialCreationResponse) {
throw new Error("Something went wrong.");
}
const browserCredentials = {
create: navigator.credentials.create.bind(
navigator.credentials,
) as typeof navigator.credentials.create,
get: navigator.credentials.get.bind(navigator.credentials) as typeof navigator.credentials.get,
};
return WebauthnUtils.mapCredentialRegistrationResult(response.result);
} catch (error) {
if (error && error.fallbackRequested && fallbackSupported) {
await waitForFocus();
const messenger = Messenger.forDOMCommunication(window);
let waitForFocusTimeout: number | NodeJS.Timeout;
let focusListenerHandler: () => void;
navigator.credentials.create = createWebAuthnCredential;
navigator.credentials.get = getWebAuthnCredential;
/**
* Creates a new webauthn credential.
*
* @param options Options for creating new credentials.
* @returns Promise that resolves to the new credential object.
*/
async function createWebAuthnCredential(
options?: CredentialCreationOptions,
): Promise<Credential> {
if (!isWebauthnCall(options)) {
return await browserCredentials.create(options);
}
throw error;
}
}
const authenticatorAttachmentIsPlatform =
options?.publicKey?.authenticatorSelection?.authenticatorAttachment === "platform";
/**
* Retrieves a webauthn credential.
*
* @param options Options for creating new credentials.
* @param abortController Abort controller to abort the request if needed.
* @returns Promise that resolves to the new credential object.
*/
async function getWebAuthnCredential(
options?: CredentialRequestOptions,
abortController?: AbortController,
): Promise<Credential> {
if (!isWebauthnCall(options)) {
return await browserCredentials.get(options);
const fallbackSupported =
(authenticatorAttachmentIsPlatform && browserNativeWebauthnPlatformAuthenticatorSupport) ||
(!authenticatorAttachmentIsPlatform && browserNativeWebauthnSupport);
try {
const response = await messenger.request(
{
type: MessageType.CredentialCreationRequest,
data: WebauthnUtils.mapCredentialCreationOptions(options, fallbackSupported),
},
options?.signal,
);
if (response.type !== MessageType.CredentialCreationResponse) {
throw new Error("Something went wrong.");
}
return WebauthnUtils.mapCredentialRegistrationResult(response.result);
} catch (error) {
if (error && error.fallbackRequested && fallbackSupported) {
await waitForFocus();
return await browserCredentials.create(options);
}
throw error;
}
}
const fallbackSupported = browserNativeWebauthnSupport;
try {
if (options?.mediation && options.mediation !== "optional") {
throw new FallbackRequestedError();
}
const response = await messenger.request(
{
type: MessageType.CredentialGetRequest,
data: WebauthnUtils.mapCredentialRequestOptions(options, fallbackSupported),
},
abortController,
);
if (response.type !== MessageType.CredentialGetResponse) {
throw new Error("Something went wrong.");
}
return WebauthnUtils.mapCredentialAssertResult(response.result);
} catch (error) {
if (error && error.fallbackRequested && fallbackSupported) {
await waitForFocus();
/**
* Retrieves a webauthn credential.
*
* @param options Options for creating new credentials.
* @returns Promise that resolves to the new credential object.
*/
async function getWebAuthnCredential(options?: CredentialRequestOptions): Promise<Credential> {
if (!isWebauthnCall(options)) {
return await browserCredentials.get(options);
}
throw error;
}
}
const fallbackSupported = browserNativeWebauthnSupport;
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
return options && "publicKey" in options;
}
try {
if (options?.mediation && options.mediation !== "optional") {
throw new FallbackRequestedError();
}
/**
* Wait for window to be focused.
* Safari doesn't allow scripts to trigger webauthn when window is not focused.
*
* @param fallbackWait How long to wait when the script is not able to add event listeners to `window.top`. Defaults to 500ms.
* @param timeout Maximum time to wait for focus in milliseconds. Defaults to 5 minutes.
* @returns Promise that resolves when window is focused, or rejects if timeout is reached.
*/
async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
try {
if (window.top.document.hasFocus()) {
return;
const response = await messenger.request(
{
type: MessageType.CredentialGetRequest,
data: WebauthnUtils.mapCredentialRequestOptions(options, fallbackSupported),
},
options?.signal,
);
if (response.type !== MessageType.CredentialGetResponse) {
throw new Error("Something went wrong.");
}
return WebauthnUtils.mapCredentialAssertResult(response.result);
} catch (error) {
if (error && error.fallbackRequested && fallbackSupported) {
await waitForFocus();
return await browserCredentials.get(options);
}
throw error;
}
} catch {
// Cannot access window.top due to cross-origin frame, fallback to waiting
return await new Promise((resolve) => window.setTimeout(resolve, fallbackWait));
}
let focusListener;
const focusPromise = new Promise<void>((resolve) => {
focusListener = () => resolve();
window.top.addEventListener("focus", focusListener);
});
let timeoutId;
const timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = window.setTimeout(
() =>
reject(
new DOMException("The operation either timed out or was not allowed.", "AbortError"),
),
timeout,
);
});
try {
await Promise.race([focusPromise, timeoutPromise]);
} finally {
window.top.removeEventListener("focus", focusListener);
window.clearTimeout(timeoutId);
}
}
/**
* Sets up a listener to handle cleanup or reconnection when the extension's
* context changes due to being reloaded or unloaded.
*/
messenger.handler = (message, abortController) => {
const type = message.type;
// Handle cleanup for disconnect request
if (type === MessageType.DisconnectRequest && browserNativeWebauthnSupport) {
navigator.credentials.create = browserCredentials.create;
navigator.credentials.get = browserCredentials.get;
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
return options && "publicKey" in options;
}
// Handle reinitialization for reconnect request
if (type === MessageType.ReconnectRequest && browserNativeWebauthnSupport) {
navigator.credentials.create = createWebAuthnCredential;
navigator.credentials.get = getWebAuthnCredential;
/**
* Wait for window to be focused.
* Safari doesn't allow scripts to trigger webauthn when window is not focused.
*
* @param fallbackWait How long to wait when the script is not able to add event listeners to `window.top`. Defaults to 500ms.
* @param timeout Maximum time to wait for focus in milliseconds. Defaults to 5 minutes.
* @returns Promise that resolves when window is focused, or rejects if timeout is reached.
*/
async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
try {
if (globalContext.top.document.hasFocus()) {
return;
}
} catch {
// Cannot access window.top due to cross-origin frame, fallback to waiting
return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait));
}
const focusPromise = new Promise<void>((resolve) => {
focusListenerHandler = () => resolve();
globalContext.top.addEventListener("focus", focusListenerHandler);
});
const timeoutPromise = new Promise<void>((_, reject) => {
waitForFocusTimeout = globalContext.setTimeout(
() =>
reject(
new DOMException("The operation either timed out or was not allowed.", "AbortError"),
),
timeout,
);
});
try {
await Promise.race([focusPromise, timeoutPromise]);
} finally {
clearWaitForFocus();
}
}
};
function clearWaitForFocus() {
globalContext.top.removeEventListener("focus", focusListenerHandler);
if (waitForFocusTimeout) {
globalContext.clearTimeout(waitForFocusTimeout);
}
}
function destroy() {
try {
if (browserNativeWebauthnSupport) {
navigator.credentials.create = browserCredentials.create;
navigator.credentials.get = browserCredentials.get;
} else {
(navigator as any).credentials = BrowserNavigatorCredentials;
globalContext.PublicKeyCredential = BrowserPublicKeyCredential;
globalContext.AuthenticatorAttestationResponse = BrowserAuthenticatorAttestationResponse;
}
clearWaitForFocus();
void messenger.destroy();
} catch (e) {
/** empty */
}
}
/**
* Sets up a listener to handle cleanup or reconnection when the extension's
* context changes due to being reloaded or unloaded.
*/
messenger.handler = (message) => {
const type = message.type;
// Handle cleanup for disconnect request
if (type === MessageType.DisconnectRequest) {
destroy();
}
};
})(globalThis);

View File

@ -0,0 +1,121 @@
import {
createAssertCredentialResultMock,
createCreateCredentialResultMock,
createCredentialCreationOptionsMock,
createCredentialRequestOptionsMock,
setupMockedWebAuthnSupport,
} from "../../../autofill/spec/fido2-testing-utils";
import { WebauthnUtils } from "../webauthn-utils";
import { MessageType } from "./messaging/message";
import { Messenger } from "./messaging/messenger";
let messenger: Messenger;
jest.mock("./messaging/messenger", () => {
return {
Messenger: class extends jest.requireActual("./messaging/messenger").Messenger {
static forDOMCommunication: any = jest.fn((window) => {
const windowOrigin = window.location.origin;
messenger = new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => window.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener),
});
messenger.destroy = jest.fn();
return messenger;
});
},
};
});
jest.mock("../webauthn-utils");
describe("Fido2 page script with native WebAuthn support", () => {
const mockCredentialCreationOptions = createCredentialCreationOptionsMock();
const mockCreateCredentialsResult = createCreateCredentialResultMock();
const mockCredentialRequestOptions = createCredentialRequestOptionsMock();
const mockCredentialAssertResult = createAssertCredentialResultMock();
setupMockedWebAuthnSupport();
require("./page-script");
afterAll(() => {
jest.clearAllMocks();
jest.resetModules();
});
describe("creating WebAuthn credentials", () => {
beforeEach(() => {
messenger.request = jest.fn().mockResolvedValue({
type: MessageType.CredentialCreationResponse,
result: mockCreateCredentialsResult,
});
});
it("falls back to the default browser credentials API if an error occurs", async () => {
window.top.document.hasFocus = jest.fn().mockReturnValue(true);
messenger.request = jest.fn().mockRejectedValue({ fallbackRequested: true });
try {
await navigator.credentials.create(mockCredentialCreationOptions);
expect("This will fail the test").toBe(true);
} catch {
expect(WebauthnUtils.mapCredentialRegistrationResult).not.toHaveBeenCalled();
}
});
it("creates and returns a WebAuthn credential when the navigator API is called to create credentials", async () => {
await navigator.credentials.create(mockCredentialCreationOptions);
expect(WebauthnUtils.mapCredentialCreationOptions).toHaveBeenCalledWith(
mockCredentialCreationOptions,
true,
);
expect(WebauthnUtils.mapCredentialRegistrationResult).toHaveBeenCalledWith(
mockCreateCredentialsResult,
);
});
});
describe("get WebAuthn credentials", () => {
beforeEach(() => {
messenger.request = jest.fn().mockResolvedValue({
type: MessageType.CredentialGetResponse,
result: mockCredentialAssertResult,
});
});
it("falls back to the default browser credentials API when an error occurs", async () => {
window.top.document.hasFocus = jest.fn().mockReturnValue(true);
messenger.request = jest.fn().mockRejectedValue({ fallbackRequested: true });
const returnValue = await navigator.credentials.get(mockCredentialRequestOptions);
expect(returnValue).toBeDefined();
expect(WebauthnUtils.mapCredentialAssertResult).not.toHaveBeenCalled();
});
it("gets and returns the WebAuthn credentials", async () => {
await navigator.credentials.get(mockCredentialRequestOptions);
expect(WebauthnUtils.mapCredentialRequestOptions).toHaveBeenCalledWith(
mockCredentialRequestOptions,
true,
);
expect(WebauthnUtils.mapCredentialAssertResult).toHaveBeenCalledWith(
mockCredentialAssertResult,
);
});
});
describe("destroy", () => {
it("should destroy the message listener when receiving a disconnect request", async () => {
jest.spyOn(globalThis.top, "removeEventListener");
const SENDER = "bitwarden-webauthn";
void messenger.handler({ type: MessageType.DisconnectRequest, SENDER, senderId: "1" });
expect(globalThis.top.removeEventListener).toHaveBeenCalledWith("focus", undefined);
expect(messenger.destroy).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,96 @@
import {
createAssertCredentialResultMock,
createCreateCredentialResultMock,
createCredentialCreationOptionsMock,
createCredentialRequestOptionsMock,
} from "../../../autofill/spec/fido2-testing-utils";
import { WebauthnUtils } from "../webauthn-utils";
import { MessageType } from "./messaging/message";
import { Messenger } from "./messaging/messenger";
let messenger: Messenger;
jest.mock("./messaging/messenger", () => {
return {
Messenger: class extends jest.requireActual("./messaging/messenger").Messenger {
static forDOMCommunication: any = jest.fn((window) => {
const windowOrigin = window.location.origin;
messenger = new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => window.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener),
});
messenger.destroy = jest.fn();
return messenger;
});
},
};
});
jest.mock("../webauthn-utils");
describe("Fido2 page script without native WebAuthn support", () => {
const mockCredentialCreationOptions = createCredentialCreationOptionsMock();
const mockCreateCredentialsResult = createCreateCredentialResultMock();
const mockCredentialRequestOptions = createCredentialRequestOptionsMock();
const mockCredentialAssertResult = createAssertCredentialResultMock();
require("./page-script");
afterAll(() => {
jest.clearAllMocks();
jest.resetModules();
});
describe("creating WebAuthn credentials", () => {
beforeEach(() => {
messenger.request = jest.fn().mockResolvedValue({
type: MessageType.CredentialCreationResponse,
result: mockCreateCredentialsResult,
});
});
it("creates and returns a WebAuthn credential", async () => {
await navigator.credentials.create(mockCredentialCreationOptions);
expect(WebauthnUtils.mapCredentialCreationOptions).toHaveBeenCalledWith(
mockCredentialCreationOptions,
false,
);
expect(WebauthnUtils.mapCredentialRegistrationResult).toHaveBeenCalledWith(
mockCreateCredentialsResult,
);
});
});
describe("get WebAuthn credentials", () => {
beforeEach(() => {
messenger.request = jest.fn().mockResolvedValue({
type: MessageType.CredentialGetResponse,
result: mockCredentialAssertResult,
});
});
it("gets and returns the WebAuthn credentials", async () => {
await navigator.credentials.get(mockCredentialRequestOptions);
expect(WebauthnUtils.mapCredentialRequestOptions).toHaveBeenCalledWith(
mockCredentialRequestOptions,
false,
);
expect(WebauthnUtils.mapCredentialAssertResult).toHaveBeenCalledWith(
mockCredentialAssertResult,
);
});
});
describe("destroy", () => {
it("should destroy the message listener when receiving a disconnect request", async () => {
jest.spyOn(globalThis.top, "removeEventListener");
const SENDER = "bitwarden-webauthn";
void messenger.handler({ type: MessageType.DisconnectRequest, SENDER, senderId: "1" });
expect(globalThis.top.removeEventListener).toHaveBeenCalledWith("focus", undefined);
expect(messenger.destroy).toHaveBeenCalled();
});
});
});

View File

@ -1,16 +0,0 @@
describe("TriggerFido2ContentScriptInjection", () => {
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
describe("init", () => {
it("sends a message to the extension background", () => {
require("../content/trigger-fido2-content-script-injection");
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "triggerFido2ContentScriptInjection",
});
});
});
});

View File

@ -1,5 +0,0 @@
(function () {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
chrome.runtime.sendMessage({ command: "triggerFido2ContentScriptInjection" });
})();

View File

@ -0,0 +1,10 @@
export const Fido2ContentScript = {
PageScript: "content/fido2/page-script.js",
PageScriptAppend: "content/fido2/page-script-append-mv2.js",
ContentScript: "content/fido2/content-script.js",
} as const;
export const Fido2ContentScriptId = {
PageScript: "fido2-page-script-registration",
ContentScript: "fido2-content-script-registration",
} as const;

View File

@ -0,0 +1,3 @@
export const Fido2PortName = {
InjectedScript: "fido2-injected-content-script-port",
} as const;

View File

@ -1,4 +0,0 @@
export abstract class Fido2Service {
init: () => Promise<void>;
injectFido2ContentScripts: (sender: chrome.runtime.MessageSender) => Promise<void>;
}

View File

@ -1,35 +0,0 @@
import { BrowserApi } from "../../platform/browser/browser-api";
import Fido2Service from "./fido2.service";
describe("Fido2Service", () => {
let fido2Service: Fido2Service;
let tabMock: chrome.tabs.Tab;
let sender: chrome.runtime.MessageSender;
beforeEach(() => {
fido2Service = new Fido2Service();
tabMock = { id: 123, url: "https://bitwarden.com" } as chrome.tabs.Tab;
sender = { tab: tabMock };
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
});
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
describe("injectFido2ContentScripts", () => {
const fido2ContentScript = "content/fido2/content-script.js";
const defaultExecuteScriptOptions = { runAt: "document_start" };
it("accepts an extension message sender and injects the fido2 scripts into the tab of the sender", async () => {
await fido2Service.injectFido2ContentScripts(sender);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: fido2ContentScript,
...defaultExecuteScriptOptions,
});
});
});
});

View File

@ -1,35 +0,0 @@
import { BrowserApi } from "../../platform/browser/browser-api";
import { Fido2Service as Fido2ServiceInterface } from "./abstractions/fido2.service";
export default class Fido2Service implements Fido2ServiceInterface {
async init() {
const tabs = await BrowserApi.tabsQuery({});
tabs.forEach((tab) => {
if (tab.url?.startsWith("https")) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.injectFido2ContentScripts({ tab } as chrome.runtime.MessageSender);
}
});
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
if (port.name === "fido2ContentScriptReady") {
port.postMessage({ command: "fido2ContentScriptInit" });
}
});
}
/**
* Injects the FIDO2 content script into the current tab.
* @param {chrome.runtime.MessageSender} sender
* @returns {Promise<void>}
*/
async injectFido2ContentScripts(sender: chrome.runtime.MessageSender): Promise<void> {
await BrowserApi.executeScriptInTab(sender.tab.id, {
file: "content/fido2/content-script.js",
frameId: sender.frameId,
runAt: "document_start",
});
}
}

View File

@ -68,6 +68,8 @@ const tabs = {
const scripting = {
executeScript: jest.fn(),
registerContentScripts: jest.fn(),
unregisterContentScripts: jest.fn(),
};
const windows = {
@ -124,6 +126,19 @@ const offscreen = {
},
};
const permissions = {
contains: jest.fn((permissions, callback) => {
callback(true);
}),
};
const webNavigation = {
onCommitted: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
};
// set chrome
global.chrome = {
i18n,
@ -137,4 +152,6 @@ global.chrome = {
privacy,
extension,
offscreen,
permissions,
webNavigation,
} as any;

View File

@ -9,6 +9,7 @@
"allowJs": true,
"sourceMap": true,
"baseUrl": ".",
"lib": ["ES2021.String"],
"paths": {
"@bitwarden/admin-console": ["../../libs/admin-console/src"],
"@bitwarden/angular/*": ["../../libs/angular/src/*"],

View File

@ -166,8 +166,6 @@ const mainConfig = {
"content/notificationBar": "./src/autofill/content/notification-bar.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
"content/content-message-handler": "./src/autofill/content/content-message-handler.ts",
"content/fido2/trigger-fido2-content-script-injection":
"./src/vault/fido2/content/trigger-fido2-content-script-injection.ts",
"content/fido2/content-script": "./src/vault/fido2/content/content-script.ts",
"content/fido2/page-script": "./src/vault/fido2/content/page-script.ts",
"notification/bar": "./src/autofill/notification/bar.ts",
@ -277,6 +275,8 @@ if (manifestVersion == 2) {
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";
mainConfig.entry["content/fido2/page-script-append-mv2"] =
"./src/vault/fido2/content/page-script-append.mv2.ts";
configs.push(mainConfig);
} else {

View File

@ -48,20 +48,26 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
) {}
async isFido2FeatureEnabled(hostname: string, origin: string): Promise<boolean> {
const userEnabledPasskeys = await firstValueFrom(this.vaultSettingsService.enablePasskeys$);
const isUserLoggedIn =
(await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut;
if (!isUserLoggedIn) {
return false;
}
const neverDomains = await firstValueFrom(this.domainSettingsService.neverDomains$);
const isExcludedDomain = neverDomains != null && hostname in neverDomains;
if (isExcludedDomain) {
return false;
}
const serverConfig = await firstValueFrom(this.configService.serverConfig$);
const isOriginEqualBitwardenVault = origin === serverConfig.environment?.vault;
if (isOriginEqualBitwardenVault) {
return false;
}
return (
userEnabledPasskeys && isUserLoggedIn && !isExcludedDomain && !isOriginEqualBitwardenVault
);
return await firstValueFrom(this.vaultSettingsService.enablePasskeys$);
}
async createCredential(
@ -70,6 +76,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
abortController = new AbortController(),
): Promise<CreateCredentialResult> {
const parsedOrigin = parse(params.origin, { allowPrivateDomains: true });
const enableFido2VaultCredentials = await this.isFido2FeatureEnabled(
parsedOrigin.hostname,
params.origin,
@ -346,7 +353,7 @@ function setAbortTimeout(
);
}
return window.setTimeout(() => abortController.abort(), clampedTimeout);
return self.setTimeout(() => abortController.abort(), clampedTimeout);
}
/**

View File

@ -0,0 +1,40 @@
import { Fido2Utils } from "./fido2-utils";
describe("Fido2 Utils", () => {
const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100];
const b64HelloWorldString = "aGVsbG8gd29ybGQ=";
describe("fromBufferToB64(...)", () => {
it("should convert an ArrayBuffer to a b64 string", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
const b64String = Fido2Utils.fromBufferToB64(buffer);
expect(b64String).toBe(b64HelloWorldString);
});
it("should return an empty string when given an empty ArrayBuffer", () => {
const buffer = new Uint8Array([]).buffer;
const b64String = Fido2Utils.fromBufferToB64(buffer);
expect(b64String).toBe("");
});
it("should return null when given null input", () => {
const b64String = Fido2Utils.fromBufferToB64(null);
expect(b64String).toBeNull();
});
});
describe("fromB64ToArray(...)", () => {
it("should convert a b64 string to an Uint8Array", () => {
const expectedArray = new Uint8Array(asciiHelloWorldArray);
const resultArray = Fido2Utils.fromB64ToArray(b64HelloWorldString);
expect(resultArray).toEqual(expectedArray);
});
it("should return null when given null input", () => {
const expectedArray = Fido2Utils.fromB64ToArray(null);
expect(expectedArray).toBeNull();
});
});
});

View File

@ -1,14 +1,20 @@
import { Utils } from "../../../platform/misc/utils";
export class Fido2Utils {
static bufferToString(bufferSource: BufferSource): string {
const buffer = Fido2Utils.bufferSourceToUint8Array(bufferSource);
let buffer: Uint8Array;
if (bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined) {
buffer = new Uint8Array(bufferSource as ArrayBuffer);
} else {
buffer = new Uint8Array(bufferSource.buffer);
}
return Utils.fromBufferToUrlB64(buffer);
return Fido2Utils.fromBufferToB64(buffer)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
static stringToBuffer(str: string): Uint8Array {
return Utils.fromUrlB64ToArray(str);
return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str));
}
static bufferSourceToUint8Array(bufferSource: BufferSource) {
@ -23,4 +29,52 @@ export class Fido2Utils {
private static isArrayBuffer(bufferSource: BufferSource): bufferSource is ArrayBuffer {
return bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined;
}
static fromB64toUrlB64(b64Str: string) {
return b64Str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
static fromBufferToB64(buffer: ArrayBuffer): string {
if (buffer == null) {
return null;
}
let binary = "";
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return globalThis.btoa(binary);
}
static fromB64ToArray(str: string): Uint8Array {
if (str == null) {
return null;
}
const binaryString = globalThis.atob(str);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
static fromUrlB64ToB64(urlB64Str: string): string {
let output = urlB64Str.replace(/-/g, "+").replace(/_/g, "/");
switch (output.length % 4) {
case 0:
break;
case 2:
output += "==";
break;
case 3:
output += "=";
break;
default:
throw new Error("Illegal base64url string!");
}
return output;
}
}