mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
PM-15998 - Update browser context menu options when the page domain is a blocked domain (#13378)
* update main context menu handler to skip creating menu entries which do not pass blocked uri checks * refactor to remove menu entries which do not pass blocked uri checks * allow context menu autofill items without a password if they have other autofillable attributes * include ciphers without passwords in autofill context menu options and track context menu state
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
type InitContextMenuItems = Omit<chrome.contextMenus.CreateProperties, "contexts"> & {
|
||||
checkPremiumAccess?: boolean;
|
||||
requiresPremiumAccess?: boolean;
|
||||
requiresUnblockedUri?: boolean;
|
||||
};
|
||||
|
||||
export { InitContextMenuItems };
|
||||
|
||||
@@ -21,7 +21,7 @@ export class CipherContextMenuHandler {
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async update(url: string) {
|
||||
async update(url: string, currentUriIsBlocked: boolean = false) {
|
||||
if (this.mainContextMenuHandler.initRunning) {
|
||||
return;
|
||||
}
|
||||
@@ -88,6 +88,10 @@ export class CipherContextMenuHandler {
|
||||
for (const cipher of ciphers) {
|
||||
await this.updateForCipher(cipher);
|
||||
}
|
||||
|
||||
if (currentUriIsBlocked) {
|
||||
await this.mainContextMenuHandler.removeBlockedUriMenuItems();
|
||||
}
|
||||
}
|
||||
|
||||
private async updateForCipher(cipher: CipherView) {
|
||||
|
||||
@@ -2,7 +2,17 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { NOOP_COMMAND_SUFFIX } from "@bitwarden/common/autofill/constants";
|
||||
import {
|
||||
AUTOFILL_CARD_ID,
|
||||
AUTOFILL_ID,
|
||||
AUTOFILL_IDENTITY_ID,
|
||||
COPY_IDENTIFIER_ID,
|
||||
COPY_PASSWORD_ID,
|
||||
COPY_USERNAME_ID,
|
||||
COPY_VERIFICATION_CODE_ID,
|
||||
NOOP_COMMAND_SUFFIX,
|
||||
SEPARATOR_ID,
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -15,6 +25,43 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { MainContextMenuHandler } from "./main-context-menu-handler";
|
||||
|
||||
/**
|
||||
* Used in place of Set method `symmetricDifference`, which is only available to node version 22.0.0 or greater:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference
|
||||
*/
|
||||
function symmetricDifference(setA: Set<string>, setB: Set<string>) {
|
||||
const _difference = new Set(setA);
|
||||
for (const elem of setB) {
|
||||
if (_difference.has(elem)) {
|
||||
_difference.delete(elem);
|
||||
} else {
|
||||
_difference.add(elem);
|
||||
}
|
||||
}
|
||||
return _difference;
|
||||
}
|
||||
|
||||
const createCipher = (data?: {
|
||||
id?: CipherView["id"];
|
||||
username?: CipherView["login"]["username"];
|
||||
password?: CipherView["login"]["password"];
|
||||
totp?: CipherView["login"]["totp"];
|
||||
viewPassword?: CipherView["viewPassword"];
|
||||
}): CipherView => {
|
||||
const { id, username, password, totp, viewPassword } = data || {};
|
||||
const cipherView = new CipherView(
|
||||
new Cipher({
|
||||
id: id ?? "1",
|
||||
type: CipherType.Login,
|
||||
viewPassword: viewPassword ?? true,
|
||||
} as any),
|
||||
);
|
||||
cipherView.login.username = username ?? "USERNAME";
|
||||
cipherView.login.password = password ?? "PASSWORD";
|
||||
cipherView.login.totp = totp ?? "TOTP";
|
||||
return cipherView;
|
||||
};
|
||||
|
||||
describe("context-menu", () => {
|
||||
let stateService: MockProxy<StateService>;
|
||||
let autofillSettingsService: MockProxy<AutofillSettingsServiceAbstraction>;
|
||||
@@ -59,6 +106,9 @@ describe("context-menu", () => {
|
||||
billingAccountProfileStateService,
|
||||
accountService,
|
||||
);
|
||||
|
||||
jest.spyOn(MainContextMenuHandler, "remove");
|
||||
|
||||
autofillSettingsService.enableContextMenu$ = of(true);
|
||||
accountService.activeAccount$ = of({
|
||||
id: "userId" as UserId,
|
||||
@@ -68,7 +118,10 @@ describe("context-menu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
afterEach(async () => {
|
||||
await MainContextMenuHandler.removeAll();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
it("has menu disabled", async () => {
|
||||
@@ -97,27 +150,6 @@ describe("context-menu", () => {
|
||||
});
|
||||
|
||||
describe("loadOptions", () => {
|
||||
const createCipher = (data?: {
|
||||
id?: CipherView["id"];
|
||||
username?: CipherView["login"]["username"];
|
||||
password?: CipherView["login"]["password"];
|
||||
totp?: CipherView["login"]["totp"];
|
||||
viewPassword?: CipherView["viewPassword"];
|
||||
}): CipherView => {
|
||||
const { id, username, password, totp, viewPassword } = data || {};
|
||||
const cipherView = new CipherView(
|
||||
new Cipher({
|
||||
id: id ?? "1",
|
||||
type: CipherType.Login,
|
||||
viewPassword: viewPassword ?? true,
|
||||
} as any),
|
||||
);
|
||||
cipherView.login.username = username ?? "USERNAME";
|
||||
cipherView.login.password = password ?? "PASSWORD";
|
||||
cipherView.login.totp = totp ?? "TOTP";
|
||||
return cipherView;
|
||||
};
|
||||
|
||||
it("is not a login cipher", async () => {
|
||||
await sut.loadOptions("TEST_TITLE", "1", {
|
||||
...createCipher(),
|
||||
@@ -128,33 +160,124 @@ describe("context-menu", () => {
|
||||
});
|
||||
|
||||
it("creates item for autofill", async () => {
|
||||
await sut.loadOptions(
|
||||
"TEST_TITLE",
|
||||
"1",
|
||||
createCipher({
|
||||
const cipher = createCipher({
|
||||
username: "",
|
||||
totp: "",
|
||||
viewPassword: false,
|
||||
}),
|
||||
viewPassword: true,
|
||||
});
|
||||
const optionId = "1";
|
||||
await sut.loadOptions("TEST_TITLE", optionId, cipher);
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(MainContextMenuHandler["existingMenuItems"].size).toEqual(2);
|
||||
|
||||
const expectedMenuItems = new Set([
|
||||
AUTOFILL_ID + `_${optionId}`,
|
||||
COPY_PASSWORD_ID + `_${optionId}`,
|
||||
]);
|
||||
|
||||
// @TODO Replace with `symmetricDifference` Set method once node 22.0.0 or higher is used
|
||||
// const expectedReceivedDiff = expectedMenuItems.symmetricDifference(MainContextMenuHandler["existingMenuItems"])
|
||||
const expectedReceivedDiff = symmetricDifference(
|
||||
expectedMenuItems,
|
||||
MainContextMenuHandler["existingMenuItems"],
|
||||
);
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
expect(expectedReceivedDiff.size).toEqual(0);
|
||||
});
|
||||
|
||||
it("create entry for each cipher piece", async () => {
|
||||
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
|
||||
await sut.loadOptions("TEST_TITLE", "1", createCipher());
|
||||
const optionId = "arbitraryString";
|
||||
await sut.loadOptions("TEST_TITLE", optionId, createCipher());
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(MainContextMenuHandler["existingMenuItems"].size).toEqual(4);
|
||||
|
||||
const expectedMenuItems = new Set([
|
||||
AUTOFILL_ID + `_${optionId}`,
|
||||
COPY_PASSWORD_ID + `_${optionId}`,
|
||||
COPY_USERNAME_ID + `_${optionId}`,
|
||||
COPY_VERIFICATION_CODE_ID + `_${optionId}`,
|
||||
]);
|
||||
|
||||
// @TODO Replace with `symmetricDifference` Set method once node 22.0.0 or higher is used
|
||||
// const expectedReceivedDiff = expectedMenuItems.symmetricDifference(MainContextMenuHandler["existingMenuItems"])
|
||||
const expectedReceivedDiff = symmetricDifference(
|
||||
expectedMenuItems,
|
||||
MainContextMenuHandler["existingMenuItems"],
|
||||
);
|
||||
|
||||
expect(expectedReceivedDiff.size).toEqual(0);
|
||||
});
|
||||
|
||||
it("creates a login/unlock item for each context menu action option when user is not authenticated", async () => {
|
||||
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
|
||||
await sut.loadOptions("TEST_TITLE", "NOOP");
|
||||
const optionId = "NOOP";
|
||||
await sut.loadOptions("TEST_TITLE", optionId);
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(6);
|
||||
|
||||
expect(MainContextMenuHandler["existingMenuItems"].size).toEqual(6);
|
||||
|
||||
const expectedMenuItems = new Set([
|
||||
AUTOFILL_ID + `_${optionId}`,
|
||||
COPY_PASSWORD_ID + `_${optionId}`,
|
||||
COPY_USERNAME_ID + `_${optionId}`,
|
||||
COPY_VERIFICATION_CODE_ID + `_${optionId}`,
|
||||
AUTOFILL_CARD_ID + `_${optionId}`,
|
||||
AUTOFILL_IDENTITY_ID + `_${optionId}`,
|
||||
]);
|
||||
|
||||
// @TODO Replace with `symmetricDifference` Set method once node 22.0.0 or higher is used
|
||||
// const expectedReceivedDiff = expectedMenuItems.symmetricDifference(MainContextMenuHandler["existingMenuItems"])
|
||||
const expectedReceivedDiff = symmetricDifference(
|
||||
expectedMenuItems,
|
||||
MainContextMenuHandler["existingMenuItems"],
|
||||
);
|
||||
|
||||
expect(expectedReceivedDiff.size).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeBlockedUriMenuItems", () => {
|
||||
it("removes menu items that require code injection", async () => {
|
||||
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
autofillSettingsService.enableContextMenu$ = of(true);
|
||||
stateService.getIsAuthenticated.mockResolvedValue(true);
|
||||
|
||||
const optionId = "1";
|
||||
await sut.loadOptions("TEST_TITLE", optionId, createCipher());
|
||||
|
||||
await sut.removeBlockedUriMenuItems();
|
||||
|
||||
expect(MainContextMenuHandler["remove"]).toHaveBeenCalledTimes(5);
|
||||
expect(MainContextMenuHandler["remove"]).toHaveBeenCalledWith(AUTOFILL_ID);
|
||||
expect(MainContextMenuHandler["remove"]).toHaveBeenCalledWith(AUTOFILL_IDENTITY_ID);
|
||||
expect(MainContextMenuHandler["remove"]).toHaveBeenCalledWith(AUTOFILL_CARD_ID);
|
||||
expect(MainContextMenuHandler["remove"]).toHaveBeenCalledWith(SEPARATOR_ID + 2);
|
||||
expect(MainContextMenuHandler["remove"]).toHaveBeenCalledWith(COPY_IDENTIFIER_ID);
|
||||
|
||||
expect(MainContextMenuHandler["existingMenuItems"].size).toEqual(4);
|
||||
|
||||
const expectedMenuItems = new Set([
|
||||
AUTOFILL_ID + `_${optionId}`,
|
||||
COPY_PASSWORD_ID + `_${optionId}`,
|
||||
COPY_USERNAME_ID + `_${optionId}`,
|
||||
COPY_VERIFICATION_CODE_ID + `_${optionId}`,
|
||||
]);
|
||||
|
||||
// @TODO Replace with `symmetricDifference` Set method once node 22.0.0 or higher is used
|
||||
// const expectedReceivedDiff = expectedMenuItems.symmetricDifference(MainContextMenuHandler["existingMenuItems"])
|
||||
const expectedReceivedDiff = symmetricDifference(
|
||||
expectedMenuItems,
|
||||
MainContextMenuHandler["existingMenuItems"],
|
||||
);
|
||||
|
||||
expect(expectedReceivedDiff.size).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { InitContextMenuItems } from "./abstractions/main-context-menu-handler";
|
||||
|
||||
export class MainContextMenuHandler {
|
||||
static existingMenuItems: Set<string> = new Set();
|
||||
initRunning = false;
|
||||
private initContextMenuItems: InitContextMenuItems[] = [
|
||||
{
|
||||
@@ -41,6 +42,7 @@ export class MainContextMenuHandler {
|
||||
id: AUTOFILL_ID,
|
||||
parentId: ROOT_ID,
|
||||
title: this.i18nService.t("autoFillLogin"),
|
||||
requiresUnblockedUri: true,
|
||||
},
|
||||
{
|
||||
id: COPY_USERNAME_ID,
|
||||
@@ -56,7 +58,7 @@ export class MainContextMenuHandler {
|
||||
id: COPY_VERIFICATION_CODE_ID,
|
||||
parentId: ROOT_ID,
|
||||
title: this.i18nService.t("copyVerificationCode"),
|
||||
checkPremiumAccess: true,
|
||||
requiresPremiumAccess: true,
|
||||
},
|
||||
{
|
||||
id: SEPARATOR_ID + 1,
|
||||
@@ -67,16 +69,19 @@ export class MainContextMenuHandler {
|
||||
id: AUTOFILL_IDENTITY_ID,
|
||||
parentId: ROOT_ID,
|
||||
title: this.i18nService.t("autoFillIdentity"),
|
||||
requiresUnblockedUri: true,
|
||||
},
|
||||
{
|
||||
id: AUTOFILL_CARD_ID,
|
||||
parentId: ROOT_ID,
|
||||
title: this.i18nService.t("autoFillCard"),
|
||||
requiresUnblockedUri: true,
|
||||
},
|
||||
{
|
||||
id: SEPARATOR_ID + 2,
|
||||
type: "separator",
|
||||
parentId: ROOT_ID,
|
||||
requiresUnblockedUri: true,
|
||||
},
|
||||
{
|
||||
id: GENERATE_PASSWORD_ID,
|
||||
@@ -87,6 +92,7 @@ export class MainContextMenuHandler {
|
||||
id: COPY_IDENTIFIER_ID,
|
||||
parentId: ROOT_ID,
|
||||
title: this.i18nService.t("copyElementIdentifier"),
|
||||
requiresUnblockedUri: true,
|
||||
},
|
||||
];
|
||||
private noCardsContextMenuItems: chrome.contextMenus.CreateProperties[] = [
|
||||
@@ -175,13 +181,19 @@ export class MainContextMenuHandler {
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
|
||||
);
|
||||
|
||||
for (const options of this.initContextMenuItems) {
|
||||
if (options.checkPremiumAccess && !hasPremium) {
|
||||
for (const menuItem of this.initContextMenuItems) {
|
||||
const {
|
||||
requiresPremiumAccess,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
requiresUnblockedUri, // destructuring this out of being passed to `create`
|
||||
...otherOptions
|
||||
} = menuItem;
|
||||
|
||||
if (requiresPremiumAccess && !hasPremium) {
|
||||
continue;
|
||||
}
|
||||
|
||||
delete options.checkPremiumAccess;
|
||||
await MainContextMenuHandler.create({ ...options, contexts: ["all"] });
|
||||
await MainContextMenuHandler.create({ ...otherOptions, contexts: ["all"] });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.warning(error.message);
|
||||
@@ -202,12 +214,16 @@ export class MainContextMenuHandler {
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
chrome.contextMenus.create(options, () => {
|
||||
const itemId = chrome.contextMenus.create(options, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
return reject(chrome.runtime.lastError);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.existingMenuItems.add(`${itemId}`);
|
||||
|
||||
return itemId;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -221,12 +237,16 @@ export class MainContextMenuHandler {
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.existingMenuItems = new Set();
|
||||
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
static remove(menuItemId: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
chrome.contextMenus.remove(menuItemId, () => {
|
||||
const itemId = chrome.contextMenus.remove(menuItemId, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(chrome.runtime.lastError);
|
||||
return;
|
||||
@@ -234,6 +254,10 @@ export class MainContextMenuHandler {
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.existingMenuItems.delete(`${itemId}`);
|
||||
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,6 +268,11 @@ export class MainContextMenuHandler {
|
||||
const createChildItem = async (parentId: string) => {
|
||||
const menuItemId = `${parentId}_${optionId}`;
|
||||
|
||||
const itemAlreadyExists = MainContextMenuHandler.existingMenuItems.has(menuItemId);
|
||||
if (itemAlreadyExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await MainContextMenuHandler.create({
|
||||
type: "normal",
|
||||
id: menuItemId,
|
||||
@@ -255,10 +284,18 @@ export class MainContextMenuHandler {
|
||||
|
||||
if (
|
||||
!cipher ||
|
||||
(cipher.type === CipherType.Login && !Utils.isNullOrEmpty(cipher.login?.password))
|
||||
(cipher.type === CipherType.Login &&
|
||||
(!Utils.isNullOrEmpty(cipher.login?.username) ||
|
||||
!Utils.isNullOrEmpty(cipher.login?.password) ||
|
||||
!Utils.isNullOrEmpty(cipher.login?.totp)))
|
||||
) {
|
||||
await createChildItem(AUTOFILL_ID);
|
||||
}
|
||||
|
||||
if (
|
||||
!cipher ||
|
||||
(cipher.type === CipherType.Login && !Utils.isNullOrEmpty(cipher.login?.password))
|
||||
) {
|
||||
if (cipher?.viewPassword ?? true) {
|
||||
await createChildItem(COPY_PASSWORD_ID);
|
||||
}
|
||||
@@ -305,10 +342,22 @@ export class MainContextMenuHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async removeBlockedUriMenuItems() {
|
||||
try {
|
||||
for (const menuItem of this.initContextMenuItems) {
|
||||
if (menuItem.requiresUnblockedUri && menuItem.id) {
|
||||
await MainContextMenuHandler.remove(menuItem.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.warning(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async noCards() {
|
||||
try {
|
||||
for (const option of this.noCardsContextMenuItems) {
|
||||
await MainContextMenuHandler.create(option);
|
||||
for (const menuItem of this.noCardsContextMenuItems) {
|
||||
await MainContextMenuHandler.create(menuItem);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.warning(error.message);
|
||||
@@ -317,8 +366,8 @@ export class MainContextMenuHandler {
|
||||
|
||||
async noIdentities() {
|
||||
try {
|
||||
for (const option of this.noIdentitiesContextMenuItems) {
|
||||
await MainContextMenuHandler.create(option);
|
||||
for (const menuItem of this.noIdentitiesContextMenuItems) {
|
||||
await MainContextMenuHandler.create(menuItem);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.warning(error.message);
|
||||
@@ -327,8 +376,8 @@ export class MainContextMenuHandler {
|
||||
|
||||
async noLogins() {
|
||||
try {
|
||||
for (const option of this.noLoginsContextMenuItems) {
|
||||
await MainContextMenuHandler.create(option);
|
||||
for (const menuItem of this.noLoginsContextMenuItems) {
|
||||
await MainContextMenuHandler.create(menuItem);
|
||||
}
|
||||
|
||||
await this.loadOptions(this.i18nService.t("addLoginMenu"), CREATE_LOGIN_ID);
|
||||
|
||||
@@ -1374,17 +1374,41 @@ export default class MainBackground {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mainContextMenuHandler?.init();
|
||||
const contextMenuIsEnabled = await this.mainContextMenuHandler?.init();
|
||||
if (!contextMenuIsEnabled) {
|
||||
this.onUpdatedRan = this.onReplacedRan = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = await BrowserApi.getTabFromCurrentWindow();
|
||||
|
||||
if (tab) {
|
||||
await this.cipherContextMenuHandler?.update(tab.url);
|
||||
const currentUriIsBlocked = await firstValueFrom(
|
||||
this.domainSettingsService.blockedInteractionsUris$.pipe(
|
||||
map((blockedInteractionsUris) => {
|
||||
if (blockedInteractionsUris && tab?.url?.length) {
|
||||
const tabURL = new URL(tab.url);
|
||||
const tabIsBlocked = Object.keys(blockedInteractionsUris).some((blockedHostname) =>
|
||||
tabURL.hostname.endsWith(blockedHostname),
|
||||
);
|
||||
|
||||
if (tabIsBlocked) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await this.cipherContextMenuHandler?.update(tab.url, currentUriIsBlocked);
|
||||
this.onUpdatedRan = this.onReplacedRan = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateOverlayCiphers() {
|
||||
// overlayBackground null in popup only contexts
|
||||
// `overlayBackground` is null in popup only contexts
|
||||
if (this.overlayBackground) {
|
||||
await this.overlayBackground.updateOverlayCiphers();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user