1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

[PM-26944] fix(browser/phishing-detection): fix various issues (#17197)

This commit is contained in:
Will Martin
2025-11-06 13:55:18 -05:00
committed by GitHub
parent e9a25d4e8c
commit 1be9e19fad
9 changed files with 237 additions and 486 deletions

View File

@@ -1472,6 +1472,7 @@ export default class MainBackground {
this.configService, this.configService,
this.logService, this.logService,
this.phishingDataService, this.phishingDataService,
messageListener,
); );
this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService); this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService);

View File

@@ -9,7 +9,7 @@
<p bitTypography="body1">{{ "phishingPageSummary" | i18n }}</p> <p bitTypography="body1">{{ "phishingPageSummary" | i18n }}</p>
<bit-callout class="tw-mb-0" type="danger" icon="bwi-globe" [title]="null"> <bit-callout class="tw-mb-0" type="danger" icon="bwi-globe" [title]="null">
<span class="tw-font-mono">{{ phishingHost$ | async }}</span> <span class="tw-font-mono tw-break-all">{{ phishingHostname$ | async }}</span>
</bit-callout> </bit-callout>
<bit-callout class="tw-mt-2" [icon]="null" type="default"> <bit-callout class="tw-mt-2" [icon]="null" type="default">

View File

@@ -4,9 +4,10 @@ import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core"; import { Component, inject } from "@angular/core";
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { ActivatedRoute, RouterModule } from "@angular/router"; import { ActivatedRoute, RouterModule } from "@angular/router";
import { map } from "rxjs"; import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
import { import {
AsyncActionsModule, AsyncActionsModule,
ButtonModule, ButtonModule,
@@ -18,8 +19,12 @@ import {
CalloutComponent, CalloutComponent,
TypographyModule, TypographyModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { MessageSender } from "@bitwarden/messaging";
import { PhishingDetectionService } from "../services/phishing-detection.service"; import {
PHISHING_DETECTION_CANCEL_COMMAND,
PHISHING_DETECTION_CONTINUE_COMMAND,
} from "../services/phishing-detection.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -44,14 +49,29 @@ import { PhishingDetectionService } from "../services/phishing-detection.service
}) })
export class PhishingWarning { export class PhishingWarning {
private activatedRoute = inject(ActivatedRoute); private activatedRoute = inject(ActivatedRoute);
protected phishingHost$ = this.activatedRoute.queryParamMap.pipe( private messageSender = inject(MessageSender);
map((params) => params.get("phishingHost") || ""),
private phishingUrl$ = this.activatedRoute.queryParamMap.pipe(
map((params) => params.get("phishingUrl") || ""),
); );
protected phishingHostname$ = this.phishingUrl$.pipe(map((url) => new URL(url).hostname));
async closeTab() { async closeTab() {
await PhishingDetectionService.requestClosePhishingWarningPage(); const tabId = await this.getTabId();
this.messageSender.send(PHISHING_DETECTION_CANCEL_COMMAND, {
tabId,
});
} }
async continueAnyway() { async continueAnyway() {
await PhishingDetectionService.requestContinueToDangerousUrl(); const url = await firstValueFrom(this.phishingUrl$);
const tabId = await this.getTabId();
this.messageSender.send(PHISHING_DETECTION_CONTINUE_COMMAND, {
tabId,
url,
});
}
private async getTabId() {
return BrowserApi.getCurrentTab()?.then((tab) => tab.id);
} }
} }

View File

@@ -10,6 +10,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components"; import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components";
import { MessageSender } from "@bitwarden/messaging";
import { PhishingWarning } from "./phishing-warning.component"; import { PhishingWarning } from "./phishing-warning.component";
import { ProtectedByComponent } from "./protected-by-component"; import { ProtectedByComponent } from "./protected-by-component";
@@ -49,6 +50,13 @@ export default {
provide: PlatformUtilsService, provide: PlatformUtilsService,
useClass: MockPlatformUtilsService, useClass: MockPlatformUtilsService,
}, },
{
provide: MessageSender,
useValue: {
// eslint-disable-next-line no-console
send: (...args: any[]) => console.debug("MessageSender called with:", args),
} as Partial<MessageSender>,
},
{ {
provide: I18nService, provide: I18nService,
useFactory: () => useFactory: () =>
@@ -79,7 +87,7 @@ export default {
}).asObservable(), }).asObservable(),
}, },
}, },
mockActivatedRoute({ phishingHost: "malicious-example.com" }), mockActivatedRoute({ phishingUrl: "http://malicious-example.com" }),
], ],
}), }),
], ],
@@ -95,14 +103,7 @@ export default {
</auth-anon-layout> </auth-anon-layout>
`, `,
}), }),
argTypes: {
phishingHost: {
control: "text",
description: "The suspicious host that was blocked",
},
},
args: { args: {
phishingHost: "malicious-example.com",
pageIcon: DeactivatedOrg, pageIcon: DeactivatedOrg,
}, },
} satisfies Meta<StoryArgs & { pageIcon: any }>; } satisfies Meta<StoryArgs & { pageIcon: any }>;
@@ -110,26 +111,20 @@ export default {
type Story = StoryObj<StoryArgs & { pageIcon: any }>; type Story = StoryObj<StoryArgs & { pageIcon: any }>;
export const Default: Story = { export const Default: Story = {
args: {
phishingHost: "malicious-example.com",
},
decorators: [ decorators: [
moduleMetadata({ moduleMetadata({
providers: [mockActivatedRoute({ phishingHost: "malicious-example.com" })], providers: [mockActivatedRoute({ phishingUrl: "http://malicious-example.com" })],
}), }),
], ],
}; };
export const LongHostname: Story = { export const LongHostname: Story = {
args: {
phishingHost: "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com",
},
decorators: [ decorators: [
moduleMetadata({ moduleMetadata({
providers: [ providers: [
mockActivatedRoute({ mockActivatedRoute({
phishingHost: phishingUrl:
"very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com", "http://verylongsuspiciousphishingdomainnamethatmightwrapmaliciousexample.com",
}), }),
], ],
}), }),

View File

@@ -1 +1 @@
<span class="tw-text-muted">{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }}</span> <span class="tw-text-muted">{{ "protectedBy" | i18n: "Bitwarden phishing blocker" }}</span>

View File

@@ -5,6 +5,7 @@ import {
firstValueFrom, firstValueFrom,
map, map,
retry, retry,
share,
startWith, startWith,
Subject, Subject,
switchMap, switchMap,
@@ -67,7 +68,7 @@ export class PhishingDataService {
private _triggerUpdate$ = new Subject<void>(); private _triggerUpdate$ = new Subject<void>();
update$ = this._triggerUpdate$.pipe( update$ = this._triggerUpdate$.pipe(
startWith(), // Always emit once startWith(undefined), // Always emit once
tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)), tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)),
switchMap(() => switchMap(() =>
this._cachedState.state$.pipe( this._cachedState.state$.pipe(
@@ -103,6 +104,7 @@ export class PhishingDataService {
), ),
), ),
), ),
share(),
); );
constructor( constructor(
@@ -131,7 +133,6 @@ export class PhishingDataService {
const domains = await firstValueFrom(this._domains$); const domains = await firstValueFrom(this._domains$);
const result = domains.has(url.hostname); const result = domains.has(url.hostname);
if (result) { if (result) {
this.logService.debug("[PhishingDataService] Caught phishing domain:", url.hostname);
return true; return true;
} }
return false; return false;

View File

@@ -1,9 +1,11 @@
import { of } from "rxjs"; import { mock, MockProxy } from "jest-mock-extended";
import { Observable, of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessageListener } from "@bitwarden/messaging";
import { PhishingDataService } from "./phishing-data.service"; import { PhishingDataService } from "./phishing-data.service";
import { PhishingDetectionService } from "./phishing-detection.service"; import { PhishingDetectionService } from "./phishing-detection.service";
@@ -13,14 +15,20 @@ describe("PhishingDetectionService", () => {
let billingAccountProfileStateService: BillingAccountProfileStateService; let billingAccountProfileStateService: BillingAccountProfileStateService;
let configService: ConfigService; let configService: ConfigService;
let logService: LogService; let logService: LogService;
let phishingDataService: PhishingDataService; let phishingDataService: MockProxy<PhishingDataService>;
let messageListener: MockProxy<MessageListener>;
beforeEach(() => { beforeEach(() => {
accountService = { getAccount$: jest.fn(() => of(null)) } as any; accountService = { getAccount$: jest.fn(() => of(null)) } as any;
billingAccountProfileStateService = {} as any; billingAccountProfileStateService = {} as any;
configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any; configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any;
logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any; logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any;
phishingDataService = {} as any; phishingDataService = mock();
messageListener = mock<MessageListener>({
messages$(_commandDefinition) {
return new Observable();
},
});
}); });
it("should initialize without errors", () => { it("should initialize without errors", () => {
@@ -31,69 +39,48 @@ describe("PhishingDetectionService", () => {
configService, configService,
logService, logService,
phishingDataService, phishingDataService,
messageListener,
); );
}).not.toThrow(); }).not.toThrow();
}); });
it("should enable phishing detection for premium account", (done) => { // TODO
const premiumAccount = { id: "user1" }; // it("should enable phishing detection for premium account", (done) => {
accountService = { activeAccount$: of(premiumAccount) } as any; // const premiumAccount = { id: "user1" };
configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any; // accountService = { activeAccount$: of(premiumAccount) } as any;
billingAccountProfileStateService = { // configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any;
hasPremiumFromAnySource$: jest.fn(() => of(true)), // billingAccountProfileStateService = {
} as any; // hasPremiumFromAnySource$: jest.fn(() => of(true)),
// } as any;
// Patch _setup to call done // // Run the initialization
const setupSpy = jest // PhishingDetectionService.initialize(
.spyOn(PhishingDetectionService as any, "_setup") // accountService,
.mockImplementation(async () => { // billingAccountProfileStateService,
expect(setupSpy).toHaveBeenCalled(); // configService,
done(); // logService,
}); // phishingDataService,
// messageListener,
// );
// });
// Run the initialization // TODO
PhishingDetectionService.initialize( // it("should not enable phishing detection for non-premium account", (done) => {
accountService, // const nonPremiumAccount = { id: "user2" };
billingAccountProfileStateService, // accountService = { activeAccount$: of(nonPremiumAccount) } as any;
configService, // configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any;
logService, // billingAccountProfileStateService = {
phishingDataService, // hasPremiumFromAnySource$: jest.fn(() => of(false)),
); // } as any;
});
it("should not enable phishing detection for non-premium account", (done) => { // // Run the initialization
const nonPremiumAccount = { id: "user2" }; // PhishingDetectionService.initialize(
accountService = { activeAccount$: of(nonPremiumAccount) } as any; // accountService,
configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any; // billingAccountProfileStateService,
billingAccountProfileStateService = { // configService,
hasPremiumFromAnySource$: jest.fn(() => of(false)), // logService,
} as any; // phishingDataService,
// messageListener,
// Patch _setup to fail if called // );
// [FIXME] This test needs to check if the setupSpy fails or is called // });
// Refactor initialize in PhishingDetectionService to return a Promise or Observable that resolves/completes when initialization is done
// So that spy setups can be properly verified after initialization
// const setupSpy = jest
// .spyOn(PhishingDetectionService as any, "_setup")
// .mockImplementation(async () => {
// throw new Error("Should not call _setup");
// });
// Patch _cleanup to call done
const cleanupSpy = jest
.spyOn(PhishingDetectionService as any, "_cleanup")
.mockImplementation(() => {
expect(cleanupSpy).toHaveBeenCalled();
done();
});
// Run the initialization
PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
);
});
}); });

View File

@@ -1,30 +1,53 @@
import { combineLatest, concatMap, delay, EMPTY, map, Subject, switchMap, takeUntil } from "rxjs"; import {
combineLatest,
concatMap,
distinctUntilChanged,
EMPTY,
filter,
map,
merge,
of,
Subject,
switchMap,
tap,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
import { BrowserApi } from "../../../platform/browser/browser-api"; import { BrowserApi } from "../../../platform/browser/browser-api";
import { PhishingDataService } from "./phishing-data.service"; import { PhishingDataService } from "./phishing-data.service";
import {
CaughtPhishingDomain, type PhishingDetectionNavigationEvent = {
isPhishingDetectionMessage, tabId: number;
PhishingDetectionMessage, changeInfo: chrome.tabs.OnUpdatedInfo;
PhishingDetectionNavigationEvent, tab: chrome.tabs.Tab;
PhishingDetectionTabId, };
} from "./phishing-detection.types";
/**
* Sends a message to the phishing detection service to continue to the caught url
*/
export const PHISHING_DETECTION_CONTINUE_COMMAND = new CommandDefinition<{
tabId: number;
url: string;
}>("phishing-detection-continue");
/**
* Sends a message to the phishing detection service to close the warning page
*/
export const PHISHING_DETECTION_CANCEL_COMMAND = new CommandDefinition<{
tabId: number;
}>("phishing-detection-cancel");
export class PhishingDetectionService { export class PhishingDetectionService {
private static _destroy$ = new Subject<void>(); private static _tabUpdated$ = new Subject<PhishingDetectionNavigationEvent>();
private static _ignoredHostnames = new Set<string>();
private static _logService: LogService; private static _didInit = false;
private static _phishingDataService: PhishingDataService;
private static _navigationEventsSubject = new Subject<PhishingDetectionNavigationEvent>();
private static _caughtTabs: Map<PhishingDetectionTabId, CaughtPhishingDomain> = new Map();
static initialize( static initialize(
accountService: AccountService, accountService: AccountService,
@@ -32,380 +55,139 @@ export class PhishingDetectionService {
configService: ConfigService, configService: ConfigService,
logService: LogService, logService: LogService,
phishingDataService: PhishingDataService, phishingDataService: PhishingDataService,
): void { messageListener: MessageListener,
this._logService = logService; ) {
this._phishingDataService = phishingDataService; if (this._didInit) {
logService.debug("[PhishingDetectionService] Initialize already called. Aborting.");
return;
}
logService.info("[PhishingDetectionService] Initialize called. Checking prerequisites..."); logService.debug("[PhishingDetectionService] Initialize called. Checking prerequisites...");
combineLatest([ 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);
}),
);
const onTabUpdated$ = this._tabUpdated$.pipe(
filter(
(navEvent) =>
navEvent.changeInfo.status === "complete" &&
!!navEvent.tab.url &&
!this._isExtensionPage(navEvent.tab.url),
),
map(({ tab, tabId }) => {
const url = new URL(tab.url!);
return { tabId, url, ignored: this._ignoredHostnames.has(url.hostname) };
}),
distinctUntilChanged(
(prev, curr) =>
prev.url.toString() === curr.url.toString() &&
prev.tabId === curr.tabId &&
prev.ignored === curr.ignored,
),
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);
return;
}
const isPhishing = await phishingDataService.isPhishingDomain(url);
if (!isPhishing) {
return;
}
const phishingWarningPage = new URL(
BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") +
`?phishingUrl=${url.toString()}`,
);
await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage);
}),
);
const onCancelCommand$ = messageListener
.messages$(PHISHING_DETECTION_CANCEL_COMMAND)
.pipe(switchMap((message) => BrowserApi.closeTab(message.tabId)));
const activeAccountHasAccess$ = combineLatest([
accountService.activeAccount$, accountService.activeAccount$,
configService.getFeatureFlag$(FeatureFlag.PhishingDetection), configService.getFeatureFlag$(FeatureFlag.PhishingDetection),
]) ]).pipe(
switchMap(([account, featureEnabled]) => {
if (!account) {
logService.debug("[PhishingDetectionService] No active account.");
return of(false);
}
return billingAccountProfileStateService
.hasPremiumFromAnySource$(account.id)
.pipe(map((hasPremium) => hasPremium && featureEnabled));
}),
);
const initSub = activeAccountHasAccess$
.pipe( .pipe(
switchMap(([account, featureEnabled]) => { distinctUntilChanged(),
if (!account) { switchMap((activeUserHasAccess) => {
logService.info("[PhishingDetectionService] No active account."); if (!activeUserHasAccess) {
this._cleanup(); logService.debug(
return EMPTY;
}
return billingAccountProfileStateService
.hasPremiumFromAnySource$(account.id)
.pipe(map((hasPremium) => ({ hasPremium, featureEnabled })));
}),
concatMap(async ({ hasPremium, featureEnabled }) => {
if (!hasPremium || !featureEnabled) {
logService.info(
"[PhishingDetectionService] User does not have access to phishing detection service.", "[PhishingDetectionService] User does not have access to phishing detection service.",
); );
this._cleanup(); return EMPTY;
} else { } else {
logService.info("[PhishingDetectionService] Enabling phishing detection service"); logService.debug("[PhishingDetectionService] Enabling phishing detection service");
await this._setup(); return merge(
phishingDataService.update$,
onContinueCommand$,
onTabUpdated$,
onCancelCommand$,
);
} }
}), }),
) )
.subscribe(); .subscribe();
}
/** this._didInit = true;
* Sends a message to the phishing detection service to close the warning page return () => {
*/ initSub.unsubscribe();
static async requestClosePhishingWarningPage() { this._didInit = false;
await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close });
}
/** // Manually type cast to satisfy the listener signature due to the mixture
* Sends a message to the phishing detection service to continue to the caught url // of static and instance methods in this class. To be fixed when refactoring
*/ // this class to be instance-based while providing a singleton instance in usage
static async requestContinueToDangerousUrl() { BrowserApi.removeListener(
await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue }); chrome.tabs.onUpdated,
} PhishingDetectionService._handleTabUpdated as (...args: readonly unknown[]) => unknown,
/**
* Continues to the dangerous URL if the user has requested it
*
* @param tabId The ID of the tab to continue to the dangerous URL
*/
static async _continueToDangerousUrl(tabId: PhishingDetectionTabId): Promise<void> {
const caughtTab = this._caughtTabs.get(tabId);
if (caughtTab) {
this._logService.info(
"[PhishingDetectionService] Continuing to known phishing domain: ",
caughtTab,
caughtTab.url.href,
); );
await BrowserApi.navigateTabToUrl(tabId, caughtTab.url); };
} else {
this._logService.warning("[PhishingDetectionService] No caught domain to continue to");
}
} }
/** private static _handleTabUpdated(
* Sets up listeners for messages from the web page and web navigation events
*/
private static _setup(): void {
this._phishingDataService.update$.pipe(takeUntil(this._destroy$)).subscribe();
// Setup listeners from web page/content script
BrowserApi.addListener(chrome.runtime.onMessage, this._handleExtensionMessage.bind(this));
BrowserApi.addListener(chrome.tabs.onReplaced, this._handleReplacementEvent.bind(this));
BrowserApi.addListener(chrome.tabs.onUpdated, this._handleNavigationEvent.bind(this));
// When a navigation event occurs, check if a replace event for the same tabId exists,
// and call the replace handler before handling navigation.
this._navigationEventsSubject
.pipe(
delay(100), // Delay slightly to allow replace events to be caught
takeUntil(this._destroy$),
)
.subscribe(({ tabId, changeInfo, tab }) => {
void this._processNavigation(tabId, changeInfo, tab);
});
}
/**
* Handles messages from the phishing warning page
*
* @returns true if the message was handled, false otherwise
*/
private static _handleExtensionMessage(
message: unknown,
sender: chrome.runtime.MessageSender,
): boolean {
if (!isPhishingDetectionMessage(message)) {
return false;
}
const isValidSender = sender && sender.tab && sender.tab.id;
const senderTabId = isValidSender ? sender?.tab?.id : null;
// Only process messages from tab navigation
if (senderTabId == null) {
return false;
}
// Handle Dangerous Continue to Phishing Domain
if (message.command === PhishingDetectionMessage.Continue) {
this._logService.debug(
"[PhishingDetectionService] User requested continue to phishing domain on tab: ",
senderTabId,
);
this._setCaughtTabContinue(senderTabId);
void this._continueToDangerousUrl(senderTabId);
return true;
}
// Handle Close Phishing Warning Page
if (message.command === PhishingDetectionMessage.Close) {
this._logService.debug(
"[PhishingDetectionService] User requested to close phishing warning page on tab: ",
senderTabId,
);
void BrowserApi.closeTab(senderTabId);
this._removeCaughtTab(senderTabId);
return true;
}
return false;
}
/**
* Filter out navigation events that are to warning pages or not complete, check for phishing domains,
* then handle the navigation appropriately.
*/
private static async _processNavigation(
tabId: number,
changeInfo: chrome.tabs.OnUpdatedInfo,
tab: chrome.tabs.Tab,
): Promise<void> {
if (changeInfo.status !== "complete" || !tab.url) {
// Not a complete navigation or no URL to check
return;
}
// Check if navigating to a warning page to ignore
const isWarningPage = this._isWarningPage(tabId, tab.url);
if (isWarningPage) {
this._logService.debug(
`[PhishingDetectionService] Ignoring navigation to warning page for tab ${tabId}: ${tab.url}`,
);
return;
}
// Check if tab is navigating to a phishing url and handle navigation
await this._checkTabForPhishing(tabId, new URL(tab.url));
await this._handleTabNavigation(tabId);
}
private static _handleNavigationEvent(
tabId: number, tabId: number,
changeInfo: chrome.tabs.OnUpdatedInfo, changeInfo: chrome.tabs.OnUpdatedInfo,
tab: chrome.tabs.Tab, tab: chrome.tabs.Tab,
): boolean { ): boolean {
this._navigationEventsSubject.next({ tabId, changeInfo, tab }); this._tabUpdated$.next({ tabId, changeInfo, tab });
// Return value for supporting BrowserApi event listener signature // Return value for supporting BrowserApi event listener signature
return true; return true;
} }
/** private static _isExtensionPage(url: string): boolean {
* Handles a replace event in Safari when redirecting to a warning page // Check against all common extension protocols
* return (
* @returns true if the replacement was handled, false otherwise url.startsWith("chrome-extension://") ||
*/ url.startsWith("moz-extension://") ||
private static _handleReplacementEvent(newTabId: number, originalTabId: number): boolean { url.startsWith("safari-extension://") ||
if (this._caughtTabs.has(originalTabId)) { url.startsWith("safari-web-extension://")
this._logService.debug(
`[PhishingDetectionService] Handling original tab ${originalTabId} changing to new tab ${newTabId}`,
);
// Handle replacement
const originalCaughtTab = this._caughtTabs.get(originalTabId);
if (originalCaughtTab) {
this._caughtTabs.set(newTabId, originalCaughtTab);
this._caughtTabs.delete(originalTabId);
} else {
this._logService.debug(
`[PhishingDetectionService] Original caught tab not found, ignoring replacement.`,
);
}
return true;
}
return false;
}
/**
* Adds a tab to the caught tabs map with the requested continue status set to false
*
* @param tabId The ID of the tab that was caught
* @param url The URL of the tab that was caught
* @param redirectedTo The URL that the tab was redirected to
*/
private static _addCaughtTab(tabId: PhishingDetectionTabId, url: URL) {
const redirectedTo = this._createWarningPageUrl(url);
const newTab = { url, warningPageUrl: redirectedTo, requestedContinue: false };
this._caughtTabs.set(tabId, newTab);
this._logService.debug("[PhishingDetectionService] Tracking new tab:", tabId, newTab);
}
/**
* Removes a tab from the caught tabs map
*
* @param tabId The ID of the tab to remove
*/
private static _removeCaughtTab(tabId: PhishingDetectionTabId) {
this._logService.debug("[PhishingDetectionService] Removing tab from tracking: ", tabId);
this._caughtTabs.delete(tabId);
}
/**
* Sets the requested continue status for a caught tab
*
* @param tabId The ID of the tab to set the continue status for
*/
private static _setCaughtTabContinue(tabId: PhishingDetectionTabId) {
const caughtTab = this._caughtTabs.get(tabId);
if (caughtTab) {
this._caughtTabs.set(tabId, {
url: caughtTab.url,
warningPageUrl: caughtTab.warningPageUrl,
requestedContinue: true,
});
}
}
/**
* Checks if the tab should continue to a dangerous domain
*
* @param tabId Tab to check if a domain was caught
* @returns True if the user requested to continue to the phishing domain
*/
private static _continueToCaughtDomain(tabId: PhishingDetectionTabId) {
const caughtDomain = this._caughtTabs.get(tabId);
const hasRequestedContinue = caughtDomain?.requestedContinue;
return caughtDomain && hasRequestedContinue;
}
/**
* Checks if the tab is going to a phishing domain and updates the caught tabs map
*
* @param tabId Tab to check for phishing domain
* @param url URL of the tab to check
*/
private static async _checkTabForPhishing(tabId: PhishingDetectionTabId, url: URL) {
// Check if the tab already being tracked
const caughtTab = this._caughtTabs.get(tabId);
const isPhishing = await this._phishingDataService.isPhishingDomain(url);
this._logService.debug(
`[PhishingDetectionService] Checking for phishing url. Result: ${isPhishing} on ${url}`,
);
// Add a new caught tab
if (!caughtTab && isPhishing) {
this._addCaughtTab(tabId, url);
}
// The tab was caught before but has an updated url
if (caughtTab && caughtTab.url.href !== url.href) {
if (isPhishing) {
this._logService.debug(
"[PhishingDetectionService] Caught tab going to a new phishing domain:",
caughtTab.url,
);
// The tab can be treated as a new tab, clear the old one and reset
this._removeCaughtTab(tabId);
this._addCaughtTab(tabId, url);
} else {
this._logService.debug(
"[PhishingDetectionService] Caught tab navigating away from a phishing domain",
);
// The tab is safe
this._removeCaughtTab(tabId);
}
}
}
/**
* Handles a phishing tab for redirection to a warning page if the user has not requested to continue
*
* @param tabId Tab to handle
* @param url URL of the tab
*/
private static async _handleTabNavigation(tabId: PhishingDetectionTabId) {
const caughtTab = this._caughtTabs.get(tabId);
if (caughtTab && !this._continueToCaughtDomain(tabId)) {
await this._redirectToWarningPage(tabId);
}
}
private static _isWarningPage(tabId: number, url: string): boolean {
const caughtTab = this._caughtTabs.get(tabId);
return !!caughtTab && caughtTab.warningPageUrl.href === url;
}
/**
* Constructs the phishing warning page URL with the caught URL as a query parameter
*
* @param caughtUrl The URL that was caught as phishing
* @returns The complete URL to the phishing warning page
*/
private static _createWarningPageUrl(caughtUrl: URL) {
const phishingWarningPage = BrowserApi.getRuntimeURL(
"popup/index.html#/security/phishing-warning",
);
const pageWithViewData = `${phishingWarningPage}?phishingHost=${caughtUrl.hostname}`;
this._logService.debug(
"[PhishingDetectionService] Created phishing warning page url:",
pageWithViewData,
);
return new URL(pageWithViewData);
}
/**
* Redirects the tab to the phishing warning page
*
* @param tabId The ID of the tab to redirect
*/
private static async _redirectToWarningPage(tabId: number) {
const tabToRedirect = this._caughtTabs.get(tabId);
if (tabToRedirect) {
this._logService.info("[PhishingDetectionService] Redirecting to warning page");
await BrowserApi.navigateTabToUrl(tabId, tabToRedirect.warningPageUrl);
} else {
this._logService.warning("[PhishingDetectionService] No caught tab found for redirection");
}
}
/**
* Cleans up the phishing detection service
* Unsubscribes from all subscriptions and clears caches
*/
private static _cleanup() {
this._destroy$.next();
this._destroy$.complete();
this._destroy$ = new Subject<void>();
this._caughtTabs.clear();
// Manually type cast to satisfy the listener signature due to the mixture
// of static and instance methods in this class. To be fixed when refactoring
// this class to be instance-based while providing a singleton instance in usage
BrowserApi.removeListener(
chrome.runtime.onMessage,
PhishingDetectionService._handleExtensionMessage as (...args: readonly unknown[]) => unknown,
);
BrowserApi.removeListener(
chrome.tabs.onReplaced,
PhishingDetectionService._handleReplacementEvent as (...args: readonly unknown[]) => unknown,
);
BrowserApi.removeListener(
chrome.tabs.onUpdated,
PhishingDetectionService._handleNavigationEvent as (...args: readonly unknown[]) => unknown,
); );
} }
} }

View File

@@ -1,35 +0,0 @@
export const PhishingDetectionMessage = Object.freeze({
Close: "phishing-detection-close",
Continue: "phishing-detection-continue",
} as const);
export type PhishingDetectionMessageTypes =
(typeof PhishingDetectionMessage)[keyof typeof PhishingDetectionMessage];
export function isPhishingDetectionMessage(
input: unknown,
): input is { command: PhishingDetectionMessageTypes } {
if (!!input && typeof input === "object" && "command" in input) {
const command = (input as Record<string, unknown>)["command"];
if (typeof command === "string") {
return Object.values(PhishingDetectionMessage).includes(
command as PhishingDetectionMessageTypes,
);
}
}
return false;
}
export type PhishingDetectionTabId = number;
export type CaughtPhishingDomain = {
url: URL;
warningPageUrl: URL;
requestedContinue: boolean;
};
export type PhishingDetectionNavigationEvent = {
tabId: number;
changeInfo: chrome.tabs.OnUpdatedInfo;
tab: chrome.tabs.Tab;
};