diff --git a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts index c948f7aa94..7b423ca4f4 100644 --- a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts +++ b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts @@ -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), ), ); } diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index b299ddccbf..8f1a8bf992 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -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(); diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index 2de35dee20..e78a1fb5ee 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -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(); diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 4db64f417d..5064d2e7df 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -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(); const eventCollectionService = mock(); const logService = mock(); @@ -74,6 +76,7 @@ describe("AutofillService", () => { const billingAccountProfileStateService = mock(); 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, }); }); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 8b33d03419..6cf58558dc 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -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, + }, }); } } diff --git a/apps/browser/src/autofill/spec/autofill-mocks.ts b/apps/browser/src/autofill/spec/autofill-mocks.ts index 708489c57e..9c957f6b1b 100644 --- a/apps/browser/src/autofill/spec/autofill-mocks.ts +++ b/apps/browser/src/autofill/spec/autofill-mocks.ts @@ -267,6 +267,7 @@ function createPortSpyMock(name: string) { disconnect: jest.fn(), sender: { tab: createChromeTabMock(), + url: "https://jest-testing-website.com", }, }); } diff --git a/apps/browser/src/autofill/spec/fido2-testing-utils.ts b/apps/browser/src/autofill/spec/fido2-testing-utils.ts new file mode 100644 index 0000000000..c9b39c16cc --- /dev/null +++ b/apps/browser/src/autofill/spec/fido2-testing-utils.ts @@ -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 { + return mock({ + publicKey: mock({ + 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 { + return mock({ + credentialId: "mock", + clientDataJSON: "mock", + attestationObject: "mock", + authData: "mock", + publicKey: "mock", + publicKeyAlgorithm: -7, + transports: ["internal"], + ...customFields, + }); +} + +export function createCredentialRequestOptionsMock( + customFields: Partial = {}, +): CredentialRequestOptions { + return mock({ + mediation: "optional", + publicKey: mock({ + allowCredentials: [{ id: new ArrayBuffer(32), type: "public-key" }], + }), + ...customFields, + }); +} + +export function createAssertCredentialResultMock( + customFields: Partial = {}, +): AssertCredentialResult { + return mock({ + 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({}), + }; +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 69ed4cfa3d..325a7f1943 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -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(); diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index a88bc051d8..44fe4818e0 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -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") { diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index aec7523d5e..78f1e2cc41 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -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'", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index d67b4affab..6c58b405f4 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -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"], diff --git a/apps/browser/src/platform/background/service-factories/browser-script-injector-service.factory.ts b/apps/browser/src/platform/background/service-factories/browser-script-injector-service.factory.ts new file mode 100644 index 0000000000..e3bc687f28 --- /dev/null +++ b/apps/browser/src/platform/background/service-factories/browser-script-injector-service.factory.ts @@ -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 { + return factory( + cache, + "browserScriptInjectorService", + opts, + async () => new BrowserScriptInjectorService(), + ); +} diff --git a/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts b/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts new file mode 100644 index 0000000000..8a20f3e999 --- /dev/null +++ b/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts @@ -0,0 +1,435 @@ +/** + * MIT License + * + * Copyright (c) Federico Brigante (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; +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(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(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 === "" || 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("")) { + // 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) { + 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; + }; +} diff --git a/apps/browser/src/platform/browser/browser-api.spec.ts b/apps/browser/src/platform/browser/browser-api.spec.ts index a1dafb38ec..e452d6d8ee 100644 --- a/apps/browser/src/platform/browser/browser-api.spec.ts +++ b/apps/browser/src/platform/browser/browser-api.spec.ts @@ -550,4 +550,35 @@ describe("BrowserApi", () => { expect(callbackMock).toHaveBeenCalled(); }); }); + + describe("registerContentScriptsMv2", () => { + const details: browser.contentScripts.RegisteredContentScriptOptions = { + matches: [""], + 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({ + 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({ + contentScripts: { register: undefined }, + }); + jest.spyOn(BrowserApi, "addListener"); + + await BrowserApi.registerContentScriptsMv2(details); + + expect(BrowserApi.addListener).toHaveBeenCalledWith( + chrome.webNavigation.onCommitted, + expect.any(Function), + ); + }); + }); }); diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index b2ee66f051..b793777d8b 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -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 { + 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 { + 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 { + await chrome.scripting.unregisterContentScripts(filter); + } } diff --git a/apps/browser/src/platform/services/abstractions/script-injector.service.ts b/apps/browser/src/platform/services/abstractions/script-injector.service.ts new file mode 100644 index 0000000000..b41e5c7617 --- /dev/null +++ b/apps/browser/src/platform/services/abstractions/script-injector.service.ts @@ -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; +} diff --git a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts new file mode 100644 index 0000000000..6ae84c6464 --- /dev/null +++ b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts @@ -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"); + }); + }); + }); + }); +}); diff --git a/apps/browser/src/platform/services/browser-script-injector.service.ts b/apps/browser/src/platform/services/browser-script-injector.service.ts new file mode 100644 index 0000000000..54513188d5 --- /dev/null +++ b/apps/browser/src/platform/services/browser-script-injector.service.ts @@ -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 { + 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 }; + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index fe70058640..4906198047 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -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"), diff --git a/apps/browser/src/tools/background/abstractions/fileless-importer.background.ts b/apps/browser/src/tools/background/abstractions/fileless-importer.background.ts index e4b8413718..2ade5bf767 100644 --- a/apps/browser/src/tools/background/abstractions/fileless-importer.background.ts +++ b/apps/browser/src/tools/background/abstractions/fileless-importer.background.ts @@ -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, diff --git a/apps/browser/src/tools/background/fileless-importer.background.spec.ts b/apps/browser/src/tools/background/fileless-importer.background.spec.ts index 858889b887..7d226fcd9d 100644 --- a/apps/browser/src/tools/background/fileless-importer.background.spec.ts +++ b/apps/browser/src/tools/background/fileless-importer.background.spec.ts @@ -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(); const importService = mock(); const syncService = mock(); + 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, + }); }); }); diff --git a/apps/browser/src/tools/background/fileless-importer.background.ts b/apps/browser/src/tools/background/fileless-importer.background.ts index 57c2faa930..07c6408e8d 100644 --- a/apps/browser/src/tools/background/fileless-importer.background.ts +++ b/apps/browser/src/tools/background/fileless-importer.background.ts @@ -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; diff --git a/apps/browser/src/tools/config/fileless-importer-injected-scripts.ts b/apps/browser/src/tools/config/fileless-importer-injected-scripts.ts index dbc05fe18c..898ee1205a 100644 --- a/apps/browser/src/tools/config/fileless-importer-injected-scripts.ts +++ b/apps/browser/src/tools/config/fileless-importer-injected-scripts.ts @@ -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; diff --git a/apps/browser/src/vault/fido2/background/abstractions/fido2.background.ts b/apps/browser/src/vault/fido2/background/abstractions/fido2.background.ts new file mode 100644 index 0000000000..49f248c7b8 --- /dev/null +++ b/apps/browser/src/vault/fido2/background/abstractions/fido2.background.ts @@ -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; + fido2GetCredentialRequest: ({ + message, + sender, + }: Fido2ExtensionMessageEventParams) => Promise; +}; + +interface Fido2Background { + init(): void; + injectFido2ContentScriptsInAllTabs(): Promise; +} + +export { + SharedFido2ScriptInjectionDetails, + SharedFido2ScriptRegistrationOptions, + Fido2ExtensionMessage, + Fido2BackgroundExtensionMessageHandlers, + Fido2Background, +}; diff --git a/apps/browser/src/vault/fido2/background/fido2.background.spec.ts b/apps/browser/src/vault/fido2/background/fido2.background.spec.ts new file mode 100644 index 0000000000..534d8a99c5 --- /dev/null +++ b/apps/browser/src/vault/fido2/background/fido2.background.spec.ts @@ -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; + let abortController!: MockProxy; + let registeredContentScripsMock!: MockProxy; + let tabMock!: MockProxy; + let senderMock!: MockProxy; + let logService!: MockProxy; + let fido2ClientService!: MockProxy; + let vaultSettingsService!: MockProxy; + let scriptInjectorServiceMock!: MockProxy; + let enablePasskeysMock$!: BehaviorSubject; + let fido2Background!: Fido2Background; + + beforeEach(() => { + tabMock = mock({ + id: 123, + url: "https://example.com", + windowId: 456, + }); + senderMock = mock({ id: "1", tab: tabMock }); + logService = mock(); + fido2ClientService = mock(); + vaultSettingsService = mock(); + abortManagerMock = mock(); + abortController = mock(); + registeredContentScripsMock = mock(); + scriptInjectorServiceMock = mock(); + + 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({ 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({ id: 456, url: "https://example.com" }); + const insecureTab = mock({ id: 789, url: "http://example.com" }); + const noUrlTab = mock({ 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; + + 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({ command: "nonexistentCommand" }); + + sendExtensionRuntimeMessage(message); + + expect(abortManagerMock.abort).not.toHaveBeenCalled(); + }); + + it("sends a response for rejected promises returned by a handler", async () => { + const message = mock({ command: "fido2RegisterCredentialRequest" }); + const sender = mock(); + 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({ + 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({ + command: "fido2RegisterCredentialRequest", + requestId: "123", + data: mock(), + }); + + 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({ + command: "fido2GetCredentialRequest", + requestId: "123", + data: mock(), + }); + + 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; + + 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; + + 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(); + }); + }); +}); diff --git a/apps/browser/src/vault/fido2/background/fido2.background.ts b/apps/browser/src/vault/fido2/background/fido2.background.ts new file mode 100644 index 0000000000..856874cee3 --- /dev/null +++ b/apps/browser/src/vault/fido2/background/fido2.background.ts @@ -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(); + 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 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 { + return await this.handleCredentialRequest( + 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 { + return await this.handleCredentialRequest( + 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 ( + { requestId, data }: Fido2ExtensionMessage, + tab: chrome.tabs.Tab, + callback: ( + data: AssertCredentialParams | CreateCredentialParams, + tab: chrome.tabs.Tab, + abortController: AbortController, + ) => Promise, + ) => { + 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); + }; +} diff --git a/apps/browser/src/vault/fido2/content/content-script.spec.ts b/apps/browser/src/vault/fido2/content/content-script.spec.ts new file mode 100644 index 0000000000..29d3e9c257 --- /dev/null +++ b/apps/browser/src/vault/fido2/content/content-script.spec.ts @@ -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 = 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({ + type: MessageType.CredentialCreationRequest, + data: mock(), + }); + 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({ + type: MessageType.CredentialGetRequest, + data: mock(), + }); + + 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({ + type: MessageType.CredentialCreationRequest, + data: mock(), + }); + 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({ + type: MessageType.CredentialCreationRequest, + data: mock(), + }); + 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({ + type: MessageType.CredentialCreationRequest, + data: mock(), + }); + 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(); + }); +}); diff --git a/apps/browser/src/vault/fido2/content/content-script.ts b/apps/browser/src/vault/fido2/content/content-script.ts index c2fc862f55..809db11553 100644 --- a/apps/browser/src/vault/fido2/content/content-script.ts +++ b/apps/browser/src/vault/fido2/content/content-script.ts @@ -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 { - 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((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((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; + } + } 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 { + 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 { + 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 { + 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); diff --git a/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts b/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts index 0c46ac39aa..5283c60882 100644 --- a/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts +++ b/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts @@ -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(); diff --git a/apps/browser/src/vault/fido2/content/messaging/messenger.ts b/apps/browser/src/vault/fido2/content/messaging/messenger.ts index cc29282227..f05c138eab 100644 --- a/apps/browser/src/vault/fido2/content/messaging/messenger.ts +++ b/apps/browser/src/vault/fido2/content/messaging/messenger.ts @@ -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 { + async request(request: Message, abortSignal?: AbortSignal): Promise { 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 }); } diff --git a/apps/browser/src/vault/fido2/content/page-script-append.mv2.spec.ts b/apps/browser/src/vault/fido2/content/page-script-append.mv2.spec.ts new file mode 100644 index 0000000000..d40a725a1f --- /dev/null +++ b/apps/browser/src/vault/fido2/content/page-script-append.mv2.spec.ts @@ -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(); + }); +}); diff --git a/apps/browser/src/vault/fido2/content/page-script-append.mv2.ts b/apps/browser/src/vault/fido2/content/page-script-append.mv2.ts new file mode 100644 index 0000000000..4e806d2990 --- /dev/null +++ b/apps/browser/src/vault/fido2/content/page-script-append.mv2.ts @@ -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); diff --git a/apps/browser/src/vault/fido2/content/page-script.ts b/apps/browser/src/vault/fido2/content/page-script.ts index 9adea68307..1de0f3258a 100644 --- a/apps/browser/src/vault/fido2/content/page-script.ts +++ b/apps/browser/src/vault/fido2/content/page-script.ts @@ -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 { - 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 { + 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 { - 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 { + 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((resolve) => { - focusListener = () => resolve(); - window.top.addEventListener("focus", focusListener); - }); - - let timeoutId; - const timeoutPromise = new Promise((_, 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((resolve) => { + focusListenerHandler = () => resolve(); + globalContext.top.addEventListener("focus", focusListenerHandler); + }); + + const timeoutPromise = new Promise((_, 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); diff --git a/apps/browser/src/vault/fido2/content/page-script.webauthn-supported.spec.ts b/apps/browser/src/vault/fido2/content/page-script.webauthn-supported.spec.ts new file mode 100644 index 0000000000..211959c466 --- /dev/null +++ b/apps/browser/src/vault/fido2/content/page-script.webauthn-supported.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/browser/src/vault/fido2/content/page-script.webauthn-unsupported.spec.ts b/apps/browser/src/vault/fido2/content/page-script.webauthn-unsupported.spec.ts new file mode 100644 index 0000000000..f3aee685e1 --- /dev/null +++ b/apps/browser/src/vault/fido2/content/page-script.webauthn-unsupported.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/browser/src/vault/fido2/content/trigger-fido2-content-script-injection.spec.ts b/apps/browser/src/vault/fido2/content/trigger-fido2-content-script-injection.spec.ts deleted file mode 100644 index 8f4efe0330..0000000000 --- a/apps/browser/src/vault/fido2/content/trigger-fido2-content-script-injection.spec.ts +++ /dev/null @@ -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", - }); - }); - }); -}); diff --git a/apps/browser/src/vault/fido2/content/trigger-fido2-content-script-injection.ts b/apps/browser/src/vault/fido2/content/trigger-fido2-content-script-injection.ts deleted file mode 100644 index 7ca6956729..0000000000 --- a/apps/browser/src/vault/fido2/content/trigger-fido2-content-script-injection.ts +++ /dev/null @@ -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" }); -})(); diff --git a/apps/browser/src/vault/fido2/enums/fido2-content-script.enum.ts b/apps/browser/src/vault/fido2/enums/fido2-content-script.enum.ts new file mode 100644 index 0000000000..287de6804b --- /dev/null +++ b/apps/browser/src/vault/fido2/enums/fido2-content-script.enum.ts @@ -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; diff --git a/apps/browser/src/vault/fido2/enums/fido2-port-name.enum.ts b/apps/browser/src/vault/fido2/enums/fido2-port-name.enum.ts new file mode 100644 index 0000000000..7836247425 --- /dev/null +++ b/apps/browser/src/vault/fido2/enums/fido2-port-name.enum.ts @@ -0,0 +1,3 @@ +export const Fido2PortName = { + InjectedScript: "fido2-injected-content-script-port", +} as const; diff --git a/apps/browser/src/vault/services/abstractions/fido2.service.ts b/apps/browser/src/vault/services/abstractions/fido2.service.ts deleted file mode 100644 index 138b538b15..0000000000 --- a/apps/browser/src/vault/services/abstractions/fido2.service.ts +++ /dev/null @@ -1,4 +0,0 @@ -export abstract class Fido2Service { - init: () => Promise; - injectFido2ContentScripts: (sender: chrome.runtime.MessageSender) => Promise; -} diff --git a/apps/browser/src/vault/services/fido2.service.spec.ts b/apps/browser/src/vault/services/fido2.service.spec.ts deleted file mode 100644 index 1db2bdfb77..0000000000 --- a/apps/browser/src/vault/services/fido2.service.spec.ts +++ /dev/null @@ -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, - }); - }); - }); -}); diff --git a/apps/browser/src/vault/services/fido2.service.ts b/apps/browser/src/vault/services/fido2.service.ts deleted file mode 100644 index 98b440b109..0000000000 --- a/apps/browser/src/vault/services/fido2.service.ts +++ /dev/null @@ -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} - */ - async injectFido2ContentScripts(sender: chrome.runtime.MessageSender): Promise { - await BrowserApi.executeScriptInTab(sender.tab.id, { - file: "content/fido2/content-script.js", - frameId: sender.frameId, - runAt: "document_start", - }); - } -} diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index 1031268186..4800b4c17f 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -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; diff --git a/apps/browser/tsconfig.json b/apps/browser/tsconfig.json index 694246f59a..505f1533ae 100644 --- a/apps/browser/tsconfig.json +++ b/apps/browser/tsconfig.json @@ -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/*"], diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 3b5724b198..2756ab4395 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -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 { diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.ts b/libs/common/src/vault/services/fido2/fido2-client.service.ts index bfc8cbe915..4e0aab017a 100644 --- a/libs/common/src/vault/services/fido2/fido2-client.service.ts +++ b/libs/common/src/vault/services/fido2/fido2-client.service.ts @@ -48,20 +48,26 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { ) {} async isFido2FeatureEnabled(hostname: string, origin: string): Promise { - 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 { 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); } /** diff --git a/libs/common/src/vault/services/fido2/fido2-utils.spec.ts b/libs/common/src/vault/services/fido2/fido2-utils.spec.ts new file mode 100644 index 0000000000..a05eab5230 --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-utils.spec.ts @@ -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(); + }); + }); +}); diff --git a/libs/common/src/vault/services/fido2/fido2-utils.ts b/libs/common/src/vault/services/fido2/fido2-utils.ts index a2de137550..13c9762135 100644 --- a/libs/common/src/vault/services/fido2/fido2-utils.ts +++ b/libs/common/src/vault/services/fido2/fido2-utils.ts @@ -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; + } }