1
0
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:
Jonathan Prusik
2025-02-18 15:27:01 -05:00
committed by GitHub
parent f798760dc5
commit a2c23aa661
5 changed files with 255 additions and 54 deletions

View File

@@ -1,5 +1,6 @@
type InitContextMenuItems = Omit<chrome.contextMenus.CreateProperties, "contexts"> & {
checkPremiumAccess?: boolean;
requiresPremiumAccess?: boolean;
requiresUnblockedUri?: boolean;
};
export { InitContextMenuItems };

View File

@@ -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) {

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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();
}