Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/browser/src/background/main.background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,7 @@ export default class MainBackground {
this.logService,
this.phishingDataService,
messageListener,
this.globalStateProvider,
);

this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { mock, MockProxy } from "jest-mock-extended";
import { Observable, of } from "rxjs";
import { firstValueFrom, Observable, of, skip, Subject } from "rxjs";

import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { FakeGlobalStateProvider } from "@bitwarden/common/spec";
import { MessageListener } from "@bitwarden/messaging";

import { BrowserApi } from "../../../platform/browser/browser-api";

import { PhishingDataService } from "./phishing-data.service";
import { PhishingDetectionService } from "./phishing-detection.service";
import {
IGNORED_PHISHING_HOSTNAMES_KEY,
PHISHING_DETECTION_CANCEL_COMMAND,
PHISHING_DETECTION_CONTINUE_COMMAND,
PhishingDetectionService,
} from "./phishing-detection.service";

jest.mock("../../../platform/browser/browser-api");

describe("PhishingDetectionService", () => {
let accountService: AccountService;
Expand All @@ -17,33 +27,240 @@ describe("PhishingDetectionService", () => {
let logService: LogService;
let phishingDataService: MockProxy<PhishingDataService>;
let messageListener: MockProxy<MessageListener>;
let globalStateProvider: FakeGlobalStateProvider;
let continueCommandSubject: Subject<{ tabId: number; url: string }>;
let cancelCommandSubject: Subject<{ tabId: number }>;

let cleanupFn: (() => void) | undefined;

beforeEach(() => {
accountService = { getAccount$: jest.fn(() => of(null)) } as any;
billingAccountProfileStateService = {} as any;
configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any;
// Mock a premium account with access to phishing detection
const mockAccount = { id: "test-user-id" };
accountService = {
getAccount$: jest.fn(() => of(mockAccount)),
activeAccount$: of(mockAccount),
} as any;
billingAccountProfileStateService = {
hasPremiumFromAnySource$: jest.fn(() => of(true)),
} as any;
configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any;
logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any;
phishingDataService = mock();
messageListener = mock<MessageListener>({
messages$(_commandDefinition) {
return new Observable();
},
phishingDataService.update$ = of(undefined);

continueCommandSubject = new Subject();
cancelCommandSubject = new Subject();

messageListener = mock<MessageListener>();
messageListener.messages$.mockImplementation((commandDefinition: any) => {
if (commandDefinition === PHISHING_DETECTION_CONTINUE_COMMAND) {
return continueCommandSubject.asObservable();
} else if (commandDefinition === PHISHING_DETECTION_CANCEL_COMMAND) {
return cancelCommandSubject.asObservable();
}
return new Observable();
});

globalStateProvider = new FakeGlobalStateProvider();

jest.spyOn(BrowserApi, "addListener").mockImplementation(() => {});
jest.spyOn(BrowserApi, "removeListener").mockImplementation(() => {});
jest.spyOn(BrowserApi, "navigateTabToUrl").mockResolvedValue(undefined);
jest.spyOn(BrowserApi, "closeTab").mockResolvedValue(undefined);
jest.spyOn(BrowserApi, "getRuntimeURL").mockReturnValue("chrome-extension://test/");
});

afterEach(() => {
if (cleanupFn) {
cleanupFn();
cleanupFn = undefined;
}
(PhishingDetectionService as any)._didInit = false;
});

it("should initialize without errors", () => {
expect(() => {
PhishingDetectionService.initialize(
cleanupFn = PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
messageListener,
globalStateProvider,
);
}).not.toThrow();
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โŒ Insufficient test coverage (30.43%): This test file only verifies that initialization doesn't throw. Critical functionality is completely untested:

  • Loading ignored hostnames from storage on init
  • Persisting hostnames when user clicks "Continue"
  • Navigation to ignored sites (should skip phishing check)
  • Error handling during storage operations
  • Cleanup/unsubscribe behavior

Please add tests covering the main user flows and error scenarios.


describe("Persistent Storage", () => {
it("loads ignored hostnames from storage on initialization", async () => {
const state = globalStateProvider.get(IGNORED_PHISHING_HOSTNAMES_KEY);
await state.update(() => ["malicious.com", "phishing.net"]);

cleanupFn = PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
messageListener,
globalStateProvider,
);

await new Promise((resolve) => setTimeout(resolve, 0));

expect(logService.debug).toHaveBeenCalledWith(
expect.stringContaining("Loaded/updated 2 ignored hostnames"),
);
});

it("persists ignored hostname when user continues to phishing site", async () => {
const state = globalStateProvider.get(IGNORED_PHISHING_HOSTNAMES_KEY);

cleanupFn = PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
messageListener,
globalStateProvider,
);

await new Promise((resolve) => setTimeout(resolve, 0));

const stateUpdatePromise = firstValueFrom(state.state$.pipe(skip(1)));

continueCommandSubject.next({
tabId: 123,
url: "https://malicious.com/login",
});

const storedHostnames = await stateUpdatePromise;
expect(storedHostnames).toContain("malicious.com");
});

it("handles multiple ignored hostnames", async () => {
const state = globalStateProvider.get(IGNORED_PHISHING_HOSTNAMES_KEY);

cleanupFn = PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
messageListener,
globalStateProvider,
);

await new Promise((resolve) => setTimeout(resolve, 0));

const firstUpdatePromise = firstValueFrom(state.state$.pipe(skip(1)));
continueCommandSubject.next({
tabId: 123,
url: "https://malicious1.com/page",
});
await firstUpdatePromise;

const secondUpdatePromise = firstValueFrom(state.state$.pipe(skip(1)));
continueCommandSubject.next({
tabId: 124,
url: "https://malicious2.com/page",
});
const storedHostnames = await secondUpdatePromise;

expect(storedHostnames).toContain("malicious1.com");
expect(storedHostnames).toContain("malicious2.com");
expect((storedHostnames as string[]).length).toBe(2);
});

it("does not duplicate hostnames when same site is ignored multiple times", async () => {
const state = globalStateProvider.get(IGNORED_PHISHING_HOSTNAMES_KEY);

cleanupFn = PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
messageListener,
globalStateProvider,
);

await new Promise((resolve) => setTimeout(resolve, 0));

const firstUpdatePromise = firstValueFrom(state.state$.pipe(skip(1)));
continueCommandSubject.next({
tabId: 123,
url: "https://malicious.com/page1",
});
await firstUpdatePromise;

const secondUpdatePromise = firstValueFrom(state.state$.pipe(skip(1)));
continueCommandSubject.next({
tabId: 124,
url: "https://malicious.com/page2",
});
const storedHostnames = await secondUpdatePromise;

const hostnameArray = storedHostnames as string[];
const count = hostnameArray.filter((h) => h === "malicious.com").length;
expect(count).toBe(1);
expect(hostnameArray.length).toBe(1);
});

it("handles storage initialization with empty state", async () => {
const state = globalStateProvider.get(IGNORED_PHISHING_HOSTNAMES_KEY);

expect(() => {
cleanupFn = PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
messageListener,
globalStateProvider,
);
}).not.toThrow();

await new Promise((resolve) => setTimeout(resolve, 0));

const stateUpdatePromise = firstValueFrom(state.state$.pipe(skip(1)));
continueCommandSubject.next({
tabId: 123,
url: "https://test.com/page",
});

const storedHostnames = await stateUpdatePromise;
expect(storedHostnames).toContain("test.com");
});

it("syncs state updates from other contexts", async () => {
const state = globalStateProvider.get(IGNORED_PHISHING_HOSTNAMES_KEY);

cleanupFn = PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
messageListener,
globalStateProvider,
);

await new Promise((resolve) => setTimeout(resolve, 0));

await state.update(() => ["external.com", "updated.com"]);

await new Promise((resolve) => setTimeout(resolve, 0));

expect(logService.debug).toHaveBeenCalledWith(
expect.stringContaining("Loaded/updated 2 ignored hostnames"),
);
});
});

// TODO
// it("should enable phishing detection for premium account", (done) => {
// const premiumAccount = { id: "user1" };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bitwarden/state";

import { BrowserApi } from "../../../platform/browser/browser-api";

Expand All @@ -44,6 +45,17 @@ export const PHISHING_DETECTION_CANCEL_COMMAND = new CommandDefinition<{
tabId: number;
}>("phishing-detection-cancel");

/**
* Key definition for storing hostnames that the user has chosen to ignore
*/
export const IGNORED_PHISHING_HOSTNAMES_KEY = new KeyDefinition<string[]>(
PHISHING_DETECTION_DISK,
"ignoredPhishingHostnames",
{
deserializer: (value: string[]) => value ?? [],
},
);
Comment on lines +48 to +57
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ“ Missing documentation: Please add a comment explaining:

  • This is global state (shared across all accounts)
  • Data persists across browser restarts
  • Not synced across devices
Suggested change
/**
* Key definition for storing hostnames that the user has chosen to ignore
*/
export const IGNORED_PHISHING_HOSTNAMES_KEY = new KeyDefinition<string[]>(
PHISHING_DETECTION_DISK,
"ignoredPhishingHostnames",
{
deserializer: (value: string[]) => value ?? [],
},
);
/**
* Key definition for storing hostnames that the user has chosen to ignore.
* This is global state (shared across all accounts) stored in disk storage.
* Data persists across browser restarts but is not synced across devices.
*/
export const IGNORED_PHISHING_HOSTNAMES_KEY = new KeyDefinition<string[]>(
PHISHING_DETECTION_DISK,
"ignoredPhishingHostnames",
{
deserializer: (value: string[]) => value ?? [],
},
);

Comment on lines +51 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): you can also use the array helper

Suggested change
export const IGNORED_PHISHING_HOSTNAMES_KEY = new KeyDefinition<string[]>(
PHISHING_DETECTION_DISK,
"ignoredPhishingHostnames",
{
deserializer: (value: string[]) => value ?? [],
},
);
export const IGNORED_PHISHING_HOSTNAMES_KEY = KeyDefinition.array<string>(
PHISHING_DETECTION_DISK,
"ignoredPhishingHostnames",
{
deserializer: (value: string) => value,
},
);


export class PhishingDetectionService {
private static _tabUpdated$ = new Subject<PhishingDetectionNavigationEvent>();
private static _ignoredHostnames = new Set<string>();
Expand All @@ -56,6 +68,7 @@ export class PhishingDetectionService {
logService: LogService,
phishingDataService: PhishingDataService,
messageListener: MessageListener,
globalStateProvider: GlobalStateProvider,
) {
if (this._didInit) {
logService.debug("[PhishingDetectionService] Initialize already called. Aborting.");
Expand All @@ -64,16 +77,44 @@ export class PhishingDetectionService {

logService.debug("[PhishingDetectionService] Initialize called. Checking prerequisites...");

const ignoredHostnamesState = globalStateProvider.get(IGNORED_PHISHING_HOSTNAMES_KEY);

// Subscribe to state changes (including initial state) to keep in-memory Set in sync
// This handles both the initial load and any future updates from other contexts
const ignoredHostnamesSub = ignoredHostnamesState.state$
.pipe(
map((hostnames) => hostnames ?? []),
tap((hostnames) => {
this._ignoredHostnames = new Set(hostnames);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: don't eject data from observable flows, it risks causing race-conditions. Just subscribe to it directly where it's needed

example:

const onContinueCommand$ = combineLatest([messageListener.messages$(PHISHING_DETECTION_CONTINUE_COMMAND), ignoredHostnamesState.state$]).pipe(
      tap(([message, ignoredHostnames]) =>
        logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`),
      ),

logService.debug(
`[PhishingDetectionService] Loaded/updated ${hostnames.length} ignored hostnames`,
);
}),
)
.subscribe();

BrowserApi.addListener(chrome.tabs.onUpdated, this._handleTabUpdated.bind(this));

const onContinueCommand$ = messageListener.messages$(PHISHING_DETECTION_CONTINUE_COMMAND).pipe(
tap((message) =>
logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`),
),
concatMap(async (message) => {
const url = new URL(message.url);
this._ignoredHostnames.add(url.hostname);
await BrowserApi.navigateTabToUrl(message.tabId, url);
try {
const url = new URL(message.url);
this._ignoredHostnames.add(url.hostname);
// Persist to storage
await ignoredHostnamesState.update(() => Array.from(this._ignoredHostnames));
Comment on lines +105 to +107
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: this is susceptible to race-conditions because awaiting ignoredHostnamesState.update can cause the execution of this function to be pre-empted by e.g. an emission of ignoredHostnamesState.state$ which would cause this._ignoredHostnames to get overwritten before the anonymous function inside update(...) has a chance to fetch the value

Either stash a copy of the Set locally or, better, don't keep a copy of state data outside of state providers and subscribe to it instead

logService.debug(
`[PhishingDetectionService] Added ${url.hostname} to ignored hostnames (persisted)`,
);
await BrowserApi.navigateTabToUrl(message.tabId, url);
} catch (error) {
logService.error(
`[PhishingDetectionService] Failed to process continue command for URL: ${message.url}`,
error,
);
}
}),
);

Expand All @@ -97,8 +138,10 @@ export class PhishingDetectionService {
tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)),
concatMap(async ({ tabId, url, ignored }) => {
if (ignored) {
// The next time this host is visited, block again
this._ignoredHostnames.delete(url.hostname);
// User has previously chosen to continue to this hostname, skip phishing check
logService.debug(
`[PhishingDetectionService] Skipping phishing check for ignored hostname: ${url.hostname}`,
);
return;
}
const isPhishing = await phishingDataService.isPhishingDomain(url);
Expand All @@ -116,7 +159,7 @@ export class PhishingDetectionService {

const onCancelCommand$ = messageListener
.messages$(PHISHING_DETECTION_CANCEL_COMMAND)
.pipe(switchMap((message) => BrowserApi.closeTab(message.tabId)));
.pipe(concatMap(async (message) => await BrowserApi.closeTab(message.tabId)));

const activeAccountHasAccess$ = combineLatest([
accountService.activeAccount$,
Expand Down Expand Up @@ -158,6 +201,7 @@ export class PhishingDetectionService {
this._didInit = true;
return () => {
initSub.unsubscribe();
ignoredHostnamesSub.unsubscribe();
this._didInit = false;

// Manually type cast to satisfy the listener signature due to the mixture
Expand Down
Loading
Loading