mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +00:00
[PM-22178] Add WebBrowserInteractionService (#15261)
* add `WebBrowserInteractionService` and check for the extension observable * update checkForExtension to use observables rather than window timeouts * add open extension to WebBrowserInteractionService * add at-risk-passwords to `PopupPageUrls` * refactor `PopupPageUrls` to `ExtensionPageUrls` * add test for passing a page * refactor `Default` to `Index` * clean up complete/next issue using `race` * refactor page to url * continue listening for messages from the extension after subscribed * mark risk passwords a deprecated * remove takeUntilDestroyed * add back `takeUntilDestroyed` for internal `messages` * removed null filter - unneeded * add tap to send message for extension installation * add check for accepted urls to prevent any bad actors from opening the extension
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
|
||||||
|
|
||||||
type ContentMessageWindowData = {
|
type ContentMessageWindowData = {
|
||||||
command: string;
|
command: string;
|
||||||
lastpass?: boolean;
|
lastpass?: boolean;
|
||||||
@@ -5,6 +7,7 @@ type ContentMessageWindowData = {
|
|||||||
state?: string;
|
state?: string;
|
||||||
data?: string;
|
data?: string;
|
||||||
remember?: boolean;
|
remember?: boolean;
|
||||||
|
url?: ExtensionPageUrls;
|
||||||
};
|
};
|
||||||
type ContentMessageWindowEventParams = {
|
type ContentMessageWindowEventParams = {
|
||||||
data: ContentMessageWindowData;
|
data: ContentMessageWindowData;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
|
||||||
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
|
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,8 @@ const windowMessageHandlers: ContentMessageWindowEventHandlers = {
|
|||||||
duoResult: ({ data, referrer }: { data: any; referrer: string }) =>
|
duoResult: ({ data, referrer }: { data: any; referrer: string }) =>
|
||||||
handleDuoResultMessage(data, referrer),
|
handleDuoResultMessage(data, referrer),
|
||||||
[VaultMessages.OpenAtRiskPasswords]: () => handleOpenAtRiskPasswordsMessage(),
|
[VaultMessages.OpenAtRiskPasswords]: () => handleOpenAtRiskPasswordsMessage(),
|
||||||
|
[VaultMessages.OpenBrowserExtensionToUrl]: ({ data }) =>
|
||||||
|
handleOpenBrowserExtensionToUrlMessage(data),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,10 +76,15 @@ function handleWebAuthnResultMessage(data: ContentMessageWindowData, referrer: s
|
|||||||
sendExtensionRuntimeMessage({ command, data: data.data, remember, referrer });
|
sendExtensionRuntimeMessage({ command, data: data.data, remember, referrer });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated use {@link handleOpenBrowserExtensionToUrlMessage} */
|
||||||
function handleOpenAtRiskPasswordsMessage() {
|
function handleOpenAtRiskPasswordsMessage() {
|
||||||
sendExtensionRuntimeMessage({ command: VaultMessages.OpenAtRiskPasswords });
|
sendExtensionRuntimeMessage({ command: VaultMessages.OpenAtRiskPasswords });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenBrowserExtensionToUrlMessage({ url }: { url?: ExtensionPageUrls }) {
|
||||||
|
sendExtensionRuntimeMessage({ command: VaultMessages.OpenBrowserExtensionToUrl, url });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the window message event.
|
* Handles the window message event.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitw
|
|||||||
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/vault/abstractions/search.service";
|
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/vault/abstractions/search.service";
|
||||||
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
|
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||||
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||||
|
import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import {
|
import {
|
||||||
DefaultEndUserNotificationService,
|
DefaultEndUserNotificationService,
|
||||||
@@ -1694,14 +1695,44 @@ export default class MainBackground {
|
|||||||
// Set route of the popup before attempting to open it.
|
// Set route of the popup before attempting to open it.
|
||||||
// If the vault is locked, this won't have an effect as the auth guards will
|
// If the vault is locked, this won't have an effect as the auth guards will
|
||||||
// redirect the user to the login page.
|
// redirect the user to the login page.
|
||||||
await browserAction.setPopup({ popup: "popup/index.html#/at-risk-passwords" });
|
await browserAction.setPopup({ popup: ExtensionPageUrls.AtRiskPasswords });
|
||||||
|
|
||||||
await this.openPopup();
|
await this.openPopup();
|
||||||
} finally {
|
} finally {
|
||||||
// Reset the popup route to the default route so any subsequent
|
// Reset the popup route to the default route so any subsequent
|
||||||
// popup openings will not open to the at-risk-passwords page.
|
// popup openings will not open to the at-risk-passwords page.
|
||||||
await browserAction.setPopup({
|
await browserAction.setPopup({
|
||||||
popup: "popup/index.html#/",
|
popup: ExtensionPageUrls.Index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the popup to the given page
|
||||||
|
* @default ExtensionPageUrls.Index
|
||||||
|
*/
|
||||||
|
async openTheExtensionToPage(url: ExtensionPageUrls = ExtensionPageUrls.Index) {
|
||||||
|
const isValidUrl = Object.values(ExtensionPageUrls).includes(url);
|
||||||
|
|
||||||
|
// If a non-defined URL is provided, return early.
|
||||||
|
if (!isValidUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserAction = BrowserApi.getBrowserAction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set route of the popup before attempting to open it.
|
||||||
|
// If the vault is locked, this won't have an effect as the auth guards will
|
||||||
|
// redirect the user to the login page.
|
||||||
|
await browserAction.setPopup({ popup: url });
|
||||||
|
|
||||||
|
await this.openPopup();
|
||||||
|
} finally {
|
||||||
|
// Reset the popup route to the default route so any subsequent
|
||||||
|
// popup openings will not open to the at-risk-passwords page.
|
||||||
|
await browserAction.setPopup({
|
||||||
|
popup: ExtensionPageUrls.Index,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ export default class RuntimeBackground {
|
|||||||
await this.main.openAtRisksPasswordsPage();
|
await this.main.openAtRisksPasswordsPage();
|
||||||
this.announcePopupOpen();
|
this.announcePopupOpen();
|
||||||
break;
|
break;
|
||||||
|
case VaultMessages.OpenBrowserExtensionToUrl:
|
||||||
|
await this.main.openTheExtensionToPage(msg.url);
|
||||||
|
this.announcePopupOpen();
|
||||||
|
break;
|
||||||
case "bgUpdateContextMenu":
|
case "bgUpdateContextMenu":
|
||||||
case "editedCipher":
|
case "editedCipher":
|
||||||
case "addedCipher":
|
case "addedCipher":
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||||
|
|
||||||
|
import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
|
||||||
|
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
|
||||||
|
|
||||||
|
import { WebBrowserInteractionService } from "./web-browser-interaction.service";
|
||||||
|
|
||||||
|
describe("WebBrowserInteractionService", () => {
|
||||||
|
let service: WebBrowserInteractionService;
|
||||||
|
const postMessage = jest.fn();
|
||||||
|
window.postMessage = postMessage;
|
||||||
|
|
||||||
|
const dispatchEvent = (command: string) => {
|
||||||
|
window.dispatchEvent(new MessageEvent("message", { data: { command } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [WebBrowserInteractionService],
|
||||||
|
});
|
||||||
|
|
||||||
|
postMessage.mockClear();
|
||||||
|
|
||||||
|
service = TestBed.inject(WebBrowserInteractionService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extensionInstalled$", () => {
|
||||||
|
it("posts a message to check for the extension", () => {
|
||||||
|
service.extensionInstalled$.subscribe();
|
||||||
|
|
||||||
|
expect(postMessage).toHaveBeenCalledWith({
|
||||||
|
command: VaultMessages.checkBwInstalled,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false after the timeout", fakeAsync(() => {
|
||||||
|
service.extensionInstalled$.subscribe((installed) => {
|
||||||
|
expect(installed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tick(1500);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("returns true when the extension is installed", (done) => {
|
||||||
|
service.extensionInstalled$.subscribe((installed) => {
|
||||||
|
expect(installed).toBe(true);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchEvent(VaultMessages.HasBwInstalled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues to listen for extension state changes after the first response", fakeAsync(() => {
|
||||||
|
const results: boolean[] = [];
|
||||||
|
|
||||||
|
service.extensionInstalled$.subscribe((installed) => {
|
||||||
|
results.push(installed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// initial timeout, should emit false
|
||||||
|
tick(1500);
|
||||||
|
expect(results[0]).toBe(false);
|
||||||
|
|
||||||
|
// then emit `HasBwInstalled`
|
||||||
|
dispatchEvent(VaultMessages.HasBwInstalled);
|
||||||
|
tick();
|
||||||
|
expect(results[1]).toBe(true);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openExtension", () => {
|
||||||
|
it("posts a message to open the extension", fakeAsync(() => {
|
||||||
|
service.openExtension().catch(() => {});
|
||||||
|
|
||||||
|
expect(postMessage).toHaveBeenCalledWith({
|
||||||
|
command: VaultMessages.OpenBrowserExtensionToUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
tick(1500);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("posts a message with the passed page", fakeAsync(() => {
|
||||||
|
service.openExtension(ExtensionPageUrls.Index).catch(() => {});
|
||||||
|
|
||||||
|
expect(postMessage).toHaveBeenCalledWith({
|
||||||
|
command: VaultMessages.OpenBrowserExtensionToUrl,
|
||||||
|
url: ExtensionPageUrls.Index,
|
||||||
|
});
|
||||||
|
|
||||||
|
tick(1500);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("resolves when the extension opens", async () => {
|
||||||
|
const openExtensionPromise = service.openExtension().catch(() => {
|
||||||
|
fail();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchEvent(VaultMessages.PopupOpened);
|
||||||
|
|
||||||
|
await openExtensionPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects if the extension does not open within the timeout", fakeAsync(() => {
|
||||||
|
service.openExtension().catch((error) => {
|
||||||
|
expect(error).toBe("Failed to open the extension");
|
||||||
|
});
|
||||||
|
|
||||||
|
tick(1500);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { DestroyRef, inject, Injectable } from "@angular/core";
|
||||||
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
|
import { concatWith, filter, fromEvent, map, Observable, race, take, tap, timer } from "rxjs";
|
||||||
|
|
||||||
|
import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
|
||||||
|
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of time in milliseconds to wait for a response from the browser extension.
|
||||||
|
* NOTE: This value isn't computed by any means, it is just a reasonable timeout for the extension to respond.
|
||||||
|
*/
|
||||||
|
const MESSAGE_RESPONSE_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: "root",
|
||||||
|
})
|
||||||
|
export class WebBrowserInteractionService {
|
||||||
|
destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
private messages$ = fromEvent<MessageEvent>(window, "message").pipe(
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Emits the installation status of the extension. */
|
||||||
|
extensionInstalled$ = this.checkForExtension().pipe(
|
||||||
|
concatWith(
|
||||||
|
this.messages$.pipe(
|
||||||
|
filter((event) => event.data.command === VaultMessages.HasBwInstalled),
|
||||||
|
map(() => true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Attempts to open the extension, rejects if the extension is not installed or it fails to open. */
|
||||||
|
openExtension = (url?: ExtensionPageUrls) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
race(
|
||||||
|
this.messages$.pipe(
|
||||||
|
filter((event) => event.data.command === VaultMessages.PopupOpened),
|
||||||
|
map(() => true),
|
||||||
|
),
|
||||||
|
timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
|
||||||
|
)
|
||||||
|
.pipe(take(1))
|
||||||
|
.subscribe((didOpen) => {
|
||||||
|
if (!didOpen) {
|
||||||
|
return reject("Failed to open the extension");
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.postMessage({ command: VaultMessages.OpenBrowserExtensionToUrl, url });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sends a message via the window object to check if the extension is installed */
|
||||||
|
private checkForExtension(): Observable<boolean> {
|
||||||
|
const checkForExtension$ = race(
|
||||||
|
this.messages$.pipe(
|
||||||
|
filter((event) => event.data.command === VaultMessages.HasBwInstalled),
|
||||||
|
map(() => true),
|
||||||
|
),
|
||||||
|
timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
|
||||||
|
).pipe(
|
||||||
|
tap({
|
||||||
|
subscribe: () => {
|
||||||
|
window.postMessage({ command: VaultMessages.checkBwInstalled });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
take(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
return checkForExtension$;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
libs/common/src/vault/enums/extension-page-urls.enum.ts
Normal file
12
libs/common/src/vault/enums/extension-page-urls.enum.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { UnionOfValues } from "../types/union-of-values";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available pages within the extension by their URL.
|
||||||
|
* Useful when opening a specific page within the popup.
|
||||||
|
*/
|
||||||
|
export const ExtensionPageUrls: Record<string, `popup/index.html#/${string}`> = {
|
||||||
|
Index: "popup/index.html#/",
|
||||||
|
AtRiskPasswords: "popup/index.html#/at-risk-passwords",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ExtensionPageUrls = UnionOfValues<typeof ExtensionPageUrls>;
|
||||||
@@ -3,3 +3,4 @@ export * from "./cipher-type";
|
|||||||
export * from "./field-type.enum";
|
export * from "./field-type.enum";
|
||||||
export * from "./linked-id-type.enum";
|
export * from "./linked-id-type.enum";
|
||||||
export * from "./secure-note-type.enum";
|
export * from "./secure-note-type.enum";
|
||||||
|
export * from "./extension-page-urls.enum";
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
const VaultMessages = {
|
const VaultMessages = {
|
||||||
HasBwInstalled: "hasBwInstalled",
|
HasBwInstalled: "hasBwInstalled",
|
||||||
checkBwInstalled: "checkIfBWExtensionInstalled",
|
checkBwInstalled: "checkIfBWExtensionInstalled",
|
||||||
|
/** @deprecated use {@link OpenBrowserExtensionToUrl} */
|
||||||
OpenAtRiskPasswords: "openAtRiskPasswords",
|
OpenAtRiskPasswords: "openAtRiskPasswords",
|
||||||
|
OpenBrowserExtensionToUrl: "openBrowserExtensionToUrl",
|
||||||
PopupOpened: "popupOpened",
|
PopupOpened: "popupOpened",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user